dfdkflj
This commit is contained in:
@@ -24,70 +24,12 @@ def register(names: Iterable[str]):
|
||||
return _wrap
|
||||
|
||||
|
||||
class AutoRegister:
|
||||
"""Decorator that automatically registers a cmdlet function using CMDLET.aliases.
|
||||
|
||||
Usage:
|
||||
CMDLET = Cmdlet(
|
||||
name="delete-file",
|
||||
aliases=["del", "del-file"],
|
||||
...
|
||||
)
|
||||
|
||||
@AutoRegister(CMDLET)
|
||||
def _run(result, args, config) -> int:
|
||||
...
|
||||
|
||||
Registers the cmdlet under:
|
||||
- Its main name from CMDLET.name
|
||||
- All aliases from CMDLET.aliases
|
||||
|
||||
This allows the help display to show: "cmd: delete-file | alias: del, del-file"
|
||||
"""
|
||||
def __init__(self, cmdlet):
|
||||
self.cmdlet = cmdlet
|
||||
|
||||
def __call__(self, fn: Cmdlet) -> Cmdlet:
|
||||
"""Register fn for the main name and all aliases in cmdlet."""
|
||||
normalized_name = None
|
||||
|
||||
# Register for main name first
|
||||
if hasattr(self.cmdlet, 'name') and self.cmdlet.name:
|
||||
normalized_name = self.cmdlet.name.replace('_', '-').lower()
|
||||
REGISTRY[normalized_name] = fn
|
||||
|
||||
# Register for all aliases
|
||||
if hasattr(self.cmdlet, 'aliases') and self.cmdlet.aliases:
|
||||
for alias in self.cmdlet.aliases:
|
||||
normalized_alias = alias.replace('_', '-').lower()
|
||||
# Always register (aliases are separate from main name)
|
||||
REGISTRY[normalized_alias] = fn
|
||||
|
||||
return fn
|
||||
|
||||
|
||||
def get(cmd_name: str) -> Cmdlet | None:
|
||||
return REGISTRY.get(cmd_name.replace('_', '-').lower())
|
||||
|
||||
|
||||
def format_cmd_help(cmdlet) -> str:
|
||||
"""Format a cmdlet for help display showing cmd:name and aliases.
|
||||
|
||||
Example output: "delete-file | aliases: del, del-file"
|
||||
"""
|
||||
if not hasattr(cmdlet, 'name'):
|
||||
return str(cmdlet)
|
||||
|
||||
cmd_str = f"cmd: {cmdlet.name}"
|
||||
|
||||
if hasattr(cmdlet, 'aliases') and cmdlet.aliases:
|
||||
aliases_str = ", ".join(cmdlet.aliases)
|
||||
cmd_str += f" | aliases: {aliases_str}"
|
||||
|
||||
return cmd_str
|
||||
|
||||
|
||||
# Dynamically import all cmdlet modules in this directory (ignore files starting with _ and __init__.py)
|
||||
# Cmdlets self-register when instantiated via their __init__ method
|
||||
import os
|
||||
cmdlet_dir = os.path.dirname(__file__)
|
||||
for filename in os.listdir(cmdlet_dir):
|
||||
@@ -106,27 +48,7 @@ for filename in os.listdir(cmdlet_dir):
|
||||
continue
|
||||
|
||||
try:
|
||||
module = _import_module(f".{mod_name}", __name__)
|
||||
|
||||
# Auto-register based on CMDLET object with exec function
|
||||
# This allows cmdlets to be fully self-contained in the CMDLET object
|
||||
if hasattr(module, 'CMDLET'):
|
||||
cmdlet_obj = module.CMDLET
|
||||
|
||||
# Get the execution function from the CMDLET object
|
||||
run_fn = getattr(cmdlet_obj, 'exec', None) if hasattr(cmdlet_obj, 'exec') else None
|
||||
|
||||
if callable(run_fn):
|
||||
# Register main name
|
||||
if hasattr(cmdlet_obj, 'name') and cmdlet_obj.name:
|
||||
normalized_name = cmdlet_obj.name.replace('_', '-').lower()
|
||||
REGISTRY[normalized_name] = run_fn
|
||||
|
||||
# Register all aliases
|
||||
if hasattr(cmdlet_obj, 'aliases') and cmdlet_obj.aliases:
|
||||
for alias in cmdlet_obj.aliases:
|
||||
normalized_alias = alias.replace('_', '-').lower()
|
||||
REGISTRY[normalized_alias] = run_fn
|
||||
_import_module(f".{mod_name}", __name__)
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"Error importing cmdlet '{mod_name}': {e}", file=sys.stderr)
|
||||
@@ -141,8 +63,6 @@ except Exception:
|
||||
pass
|
||||
|
||||
# Import root-level modules that also register cmdlets
|
||||
# Note: search_libgen, search_soulseek, and search_debrid are now consolidated into search_provider.py
|
||||
# Use search-file -provider libgen, -provider soulseek, or -provider debrid instead
|
||||
for _root_mod in ("select_cmdlet",):
|
||||
try:
|
||||
_import_module(_root_mod)
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
import inspect
|
||||
from collections.abc import Iterable as IterableABC
|
||||
|
||||
from helper.logger import log
|
||||
from helper.logger import log, debug
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set
|
||||
from dataclasses import dataclass, field
|
||||
@@ -37,22 +37,9 @@ class CmdletArg:
|
||||
"""Optional handler function/callable for processing this argument's value"""
|
||||
variadic: bool = False
|
||||
"""Whether this argument accepts multiple values (consumes remaining positional args)"""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dict for backward compatibility."""
|
||||
d = {
|
||||
"name": self.name,
|
||||
"type": self.type,
|
||||
"required": self.required,
|
||||
"description": self.description,
|
||||
"variadic": self.variadic,
|
||||
}
|
||||
if self.choices:
|
||||
d["choices"] = self.choices
|
||||
if self.alias:
|
||||
d["alias"] = self.alias
|
||||
return d
|
||||
|
||||
usage: str = ""
|
||||
"""dsf"""
|
||||
|
||||
def resolve(self, value: Any) -> Any:
|
||||
"""Resolve/process the argument value using the handler if available.
|
||||
|
||||
@@ -135,11 +122,68 @@ class SharedArgs:
|
||||
|
||||
# File/Hash arguments
|
||||
HASH = CmdletArg(
|
||||
"hash",
|
||||
name="hash",
|
||||
type="string",
|
||||
description="Override the Hydrus file hash (SHA256) to target instead of the selected result."
|
||||
description="File hash (SHA256, 64-char hex string)",
|
||||
)
|
||||
|
||||
STORE = CmdletArg(
|
||||
name="store",
|
||||
type="enum",
|
||||
choices=[], # Dynamically populated via get_store_choices()
|
||||
description="Selects store",
|
||||
)
|
||||
|
||||
PATH = CmdletArg(
|
||||
name="path",
|
||||
type="string",
|
||||
choices=[], # Dynamically populated via get_store_choices()
|
||||
description="Selects store",
|
||||
)
|
||||
|
||||
URL = CmdletArg(
|
||||
name="url",
|
||||
type="string",
|
||||
description="http parser",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_store_choices(config: Optional[Dict[str, Any]] = None) -> List[str]:
|
||||
"""Get list of available storage backend names from FileStorage.
|
||||
|
||||
This method dynamically discovers all configured storage backends
|
||||
instead of using a static list. Should be called when building
|
||||
autocomplete choices or validating store names.
|
||||
|
||||
Args:
|
||||
config: Optional config dict. If not provided, will try to load from config module.
|
||||
|
||||
Returns:
|
||||
List of backend names (e.g., ['default', 'test', 'home', 'work'])
|
||||
|
||||
Example:
|
||||
# In a cmdlet that needs dynamic choices
|
||||
from helper.store import FileStorage
|
||||
storage = FileStorage(config)
|
||||
SharedArgs.STORE.choices = SharedArgs.get_store_choices(config)
|
||||
"""
|
||||
try:
|
||||
from helper.store import FileStorage
|
||||
|
||||
# If no config provided, try to load it
|
||||
if config is None:
|
||||
try:
|
||||
from config import load_config
|
||||
config = load_config()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
file_storage = FileStorage(config)
|
||||
return file_storage.list_backends()
|
||||
except Exception:
|
||||
# Fallback to empty list if FileStorage isn't available
|
||||
return []
|
||||
|
||||
LOCATION = CmdletArg(
|
||||
"location",
|
||||
type="enum",
|
||||
@@ -205,16 +249,7 @@ class SharedArgs:
|
||||
type="string",
|
||||
description="Output file path."
|
||||
)
|
||||
|
||||
STORAGE = CmdletArg(
|
||||
"storage",
|
||||
type="enum",
|
||||
choices=["hydrus", "local", "ftp", "matrix"],
|
||||
required=False,
|
||||
description="Storage location or destination for saving/uploading files.",
|
||||
alias="s",
|
||||
handler=lambda val: SharedArgs.resolve_storage(val) if val else None
|
||||
)
|
||||
|
||||
|
||||
# Generic arguments
|
||||
QUERY = CmdletArg(
|
||||
@@ -325,78 +360,61 @@ class Cmdlet:
|
||||
log(cmd.name) # "add-file"
|
||||
log(cmd.summary) # "Upload a media file"
|
||||
log(cmd.args[0].name) # "location"
|
||||
|
||||
# Convert to dict for JSON serialization
|
||||
log(json.dumps(cmd.to_dict()))
|
||||
"""
|
||||
name: str
|
||||
"""Cmdlet name, e.g., 'add-file'"""
|
||||
""""""
|
||||
summary: str
|
||||
"""One-line summary of the cmdlet"""
|
||||
usage: str
|
||||
"""Usage string, e.g., 'add-file <location> [-delete]'"""
|
||||
aliases: List[str] = field(default_factory=list)
|
||||
alias: List[str] = field(default_factory=list)
|
||||
"""List of aliases for this cmdlet, e.g., ['add', 'add-f']"""
|
||||
args: List[CmdletArg] = field(default_factory=list)
|
||||
arg: List[CmdletArg] = field(default_factory=list)
|
||||
"""List of arguments accepted by this cmdlet"""
|
||||
details: List[str] = field(default_factory=list)
|
||||
detail: List[str] = field(default_factory=list)
|
||||
"""Detailed explanation lines (for help text)"""
|
||||
exec: Optional[Any] = field(default=None)
|
||||
"""The execution function: func(result, args, config) -> int"""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Auto-discover _run function if exec not explicitly provided.
|
||||
|
||||
If exec is None, looks for a _run function in the module where
|
||||
this Cmdlet was instantiated and uses it automatically.
|
||||
"""
|
||||
if self.exec is None:
|
||||
# Walk up the call stack to find _run in the calling module
|
||||
frame = inspect.currentframe()
|
||||
try:
|
||||
# Walk up frames until we find one with _run in globals
|
||||
while frame:
|
||||
if '_run' in frame.f_globals:
|
||||
self.exec = frame.f_globals['_run']
|
||||
break
|
||||
frame = frame.f_back
|
||||
finally:
|
||||
del frame # Avoid reference cycles
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dict for backward compatibility with existing code.
|
||||
|
||||
Returns a dict matching the old CMDLET format so existing code
|
||||
that expects a dict will still work.
|
||||
"""
|
||||
# Format command for display: "cmd: name alias: alias1, alias2"
|
||||
cmd_display = f"cmd: {self.name}"
|
||||
if self.aliases:
|
||||
aliases_str = ", ".join(self.aliases)
|
||||
cmd_display += f" alias: {aliases_str}"
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"summary": self.summary,
|
||||
"usage": self.usage,
|
||||
"cmd": cmd_display, # Display-friendly command name with aliases on one line
|
||||
"aliases": self.aliases,
|
||||
"args": [arg.to_dict() for arg in self.args],
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Dict-like access for backward compatibility.
|
||||
|
||||
Allows code like: cmdlet["name"] or cmdlet["args"]
|
||||
"""
|
||||
d = self.to_dict()
|
||||
return d.get(key)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Dict-like get() method for backward compatibility."""
|
||||
d = self.to_dict()
|
||||
return d.get(key, default)
|
||||
|
||||
|
||||
def _collect_names(self) -> List[str]:
|
||||
"""Collect primary name plus aliases, de-duplicated and normalized."""
|
||||
names: List[str] = []
|
||||
if self.name:
|
||||
names.append(self.name)
|
||||
for alias in (self.alias or []):
|
||||
if alias:
|
||||
names.append(alias)
|
||||
for alias in (getattr(self, "aliases", None) or []):
|
||||
if alias:
|
||||
names.append(alias)
|
||||
|
||||
seen: Set[str] = set()
|
||||
deduped: List[str] = []
|
||||
for name in names:
|
||||
key = name.replace("_", "-").lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(name)
|
||||
return deduped
|
||||
|
||||
def register(self) -> "Cmdlet":
|
||||
"""Register this cmdlet's exec under its name and aliases."""
|
||||
if not callable(self.exec):
|
||||
return self
|
||||
try:
|
||||
from . import register as _register # Local import to avoid circular import cost
|
||||
except Exception:
|
||||
return self
|
||||
|
||||
names = self._collect_names()
|
||||
if not names:
|
||||
return self
|
||||
|
||||
_register(names)(self.exec)
|
||||
return self
|
||||
|
||||
def get_flags(self, arg_name: str) -> set[str]:
|
||||
"""Generate -name and --name flag variants for an argument.
|
||||
@@ -432,7 +450,7 @@ class Cmdlet:
|
||||
elif low in flags.get('tag', set()):
|
||||
# handle tag
|
||||
"""
|
||||
return {arg.name: self.get_flags(arg.name) for arg in self.args}
|
||||
return {arg.name: self.get_flags(arg.name) for arg in self.arg}
|
||||
|
||||
|
||||
# Tag groups cache (loaded from JSON config file)
|
||||
@@ -479,19 +497,19 @@ def parse_cmdlet_args(args: Sequence[str], cmdlet_spec: Dict[str, Any] | Cmdlet)
|
||||
"""
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
# Handle both dict and Cmdlet objects
|
||||
if isinstance(cmdlet_spec, Cmdlet):
|
||||
cmdlet_spec = cmdlet_spec.to_dict()
|
||||
# Only accept Cmdlet objects
|
||||
if not isinstance(cmdlet_spec, Cmdlet):
|
||||
raise TypeError(f"Expected Cmdlet, got {type(cmdlet_spec).__name__}")
|
||||
|
||||
# Build arg specs tracking which are positional vs flagged
|
||||
arg_specs: List[Dict[str, Any]] = cmdlet_spec.get("args", [])
|
||||
positional_args: List[Dict[str, Any]] = [] # args without prefix in definition
|
||||
flagged_args: List[Dict[str, Any]] = [] # args with prefix in definition
|
||||
# Build arg specs from cmdlet
|
||||
arg_specs: List[CmdletArg] = cmdlet_spec.arg
|
||||
positional_args: List[CmdletArg] = [] # args without prefix in definition
|
||||
flagged_args: List[CmdletArg] = [] # args with prefix in definition
|
||||
|
||||
arg_spec_map: Dict[str, str] = {} # prefix variant -> canonical name (without prefix)
|
||||
|
||||
for spec in arg_specs:
|
||||
name = spec.get("name")
|
||||
name = spec.name
|
||||
if not name:
|
||||
continue
|
||||
|
||||
@@ -520,10 +538,10 @@ def parse_cmdlet_args(args: Sequence[str], cmdlet_spec: Dict[str, Any] | Cmdlet)
|
||||
# Check if this token is a known flagged argument
|
||||
if token_lower in arg_spec_map:
|
||||
canonical_name = arg_spec_map[token_lower]
|
||||
spec = next((s for s in arg_specs if str(s.get("name", "")).lstrip("-").lower() == canonical_name.lower()), None)
|
||||
spec = next((s for s in arg_specs if str(s.name).lstrip("-").lower() == canonical_name.lower()), None)
|
||||
|
||||
# Check if it's a flag type (which doesn't consume next value, just marks presence)
|
||||
is_flag = spec and spec.get("type") == "flag"
|
||||
is_flag = spec and spec.type == "flag"
|
||||
|
||||
if is_flag:
|
||||
# For flags, just mark presence without consuming next token
|
||||
@@ -535,7 +553,7 @@ def parse_cmdlet_args(args: Sequence[str], cmdlet_spec: Dict[str, Any] | Cmdlet)
|
||||
value = args[i + 1]
|
||||
|
||||
# Check if variadic
|
||||
is_variadic = spec and spec.get("variadic", False)
|
||||
is_variadic = spec and spec.variadic
|
||||
if is_variadic:
|
||||
if canonical_name not in result:
|
||||
result[canonical_name] = []
|
||||
@@ -550,8 +568,8 @@ def parse_cmdlet_args(args: Sequence[str], cmdlet_spec: Dict[str, Any] | Cmdlet)
|
||||
# Otherwise treat as positional if we have positional args remaining
|
||||
elif positional_index < len(positional_args):
|
||||
positional_spec = positional_args[positional_index]
|
||||
canonical_name = str(positional_spec.get("name", "")).lstrip("-")
|
||||
is_variadic = positional_spec.get("variadic", False)
|
||||
canonical_name = str(positional_spec.name).lstrip("-")
|
||||
is_variadic = positional_spec.variadic
|
||||
|
||||
if is_variadic:
|
||||
# For variadic args, append to a list
|
||||
@@ -591,6 +609,183 @@ def normalize_hash(hash_hex: Optional[str]) -> Optional[str]:
|
||||
return text.lower() if text else None
|
||||
|
||||
|
||||
def get_hash_for_operation(override_hash: Optional[str], result: Any, field_name: str = "hash_hex") -> Optional[str]:
|
||||
"""Get normalized hash from override or result object, consolidating common pattern.
|
||||
|
||||
Eliminates repeated pattern: normalize_hash(override) if override else normalize_hash(get_field(result, ...))
|
||||
|
||||
Args:
|
||||
override_hash: Hash passed as command argument (takes precedence)
|
||||
result: Object containing hash field (fallback)
|
||||
field_name: Name of hash field in result object (default: "hash_hex")
|
||||
|
||||
Returns:
|
||||
Normalized hash string, or None if neither override nor result provides valid hash
|
||||
"""
|
||||
if override_hash:
|
||||
return normalize_hash(override_hash)
|
||||
# Try multiple field names for robustness
|
||||
hash_value = get_field(result, field_name) or getattr(result, field_name, None) or getattr(result, "hash", None) or result.get("file_hash") if isinstance(result, dict) else None
|
||||
return normalize_hash(hash_value)
|
||||
|
||||
|
||||
def fetch_hydrus_metadata(config: Any, hash_hex: str, **kwargs) -> tuple[Optional[Dict[str, Any]], Optional[int]]:
|
||||
"""Fetch metadata from Hydrus for a given hash, consolidating common fetch pattern.
|
||||
|
||||
Eliminates repeated boilerplate: client initialization, error handling, metadata extraction.
|
||||
|
||||
Args:
|
||||
config: Configuration object (passed to hydrus_wrapper.get_client)
|
||||
hash_hex: File hash to fetch metadata for
|
||||
**kwargs: Additional arguments to pass to client.fetch_file_metadata()
|
||||
Common: include_service_keys_to_tags, include_notes, include_file_url, include_duration, etc.
|
||||
|
||||
Returns:
|
||||
Tuple of (metadata_dict, error_code)
|
||||
- metadata_dict: Dict from Hydrus (first item in metadata list) or None if unavailable
|
||||
- error_code: 0 on success, 1 on any error (suitable for returning from cmdlet execute())
|
||||
"""
|
||||
from helper import hydrus
|
||||
hydrus_wrapper = hydrus
|
||||
|
||||
try:
|
||||
client = hydrus_wrapper.get_client(config)
|
||||
except Exception as exc:
|
||||
log(f"Hydrus client unavailable: {exc}")
|
||||
return None, 1
|
||||
|
||||
if client is None:
|
||||
log("Hydrus client unavailable")
|
||||
return None, 1
|
||||
|
||||
try:
|
||||
payload = client.fetch_file_metadata(hashes=[hash_hex], **kwargs)
|
||||
except Exception as exc:
|
||||
log(f"Hydrus metadata fetch failed: {exc}")
|
||||
return None, 1
|
||||
|
||||
items = payload.get("metadata") if isinstance(payload, dict) else None
|
||||
meta = items[0] if (isinstance(items, list) and items and isinstance(items[0], dict)) else None
|
||||
|
||||
return meta, 0
|
||||
|
||||
|
||||
def get_origin(obj: Any, default: Optional[str] = None) -> Optional[str]:
|
||||
"""Extract origin field with fallback to store/source field, consolidating common pattern.
|
||||
|
||||
Supports both dict and object access patterns.
|
||||
|
||||
Args:
|
||||
obj: Object (dict or dataclass) with 'store', 'origin', or 'source' field
|
||||
default: Default value if none of the fields are found
|
||||
|
||||
Returns:
|
||||
Store/origin/source string, or default if none exist
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get("store") or obj.get("origin") or obj.get("source") or default
|
||||
else:
|
||||
return getattr(obj, "store", None) or getattr(obj, "origin", None) or getattr(obj, "source", None) or default
|
||||
|
||||
|
||||
def get_field(obj: Any, field: str, default: Optional[Any] = None) -> Any:
|
||||
"""Extract a field from either a dict or object with fallback default.
|
||||
|
||||
Handles both dict.get(field) and getattr(obj, field) access patterns.
|
||||
Also handles lists by accessing the first element.
|
||||
For PipeObjects, checks the extra field as well.
|
||||
Used throughout cmdlets to uniformly access fields from mixed types.
|
||||
|
||||
Args:
|
||||
obj: Dict, object, or list to extract from
|
||||
field: Field name to retrieve
|
||||
default: Value to return if field not found (default: None)
|
||||
|
||||
Returns:
|
||||
Field value if found, otherwise the default value
|
||||
|
||||
Examples:
|
||||
get_field(result, "hash") # From dict or object
|
||||
get_field(result, "origin", "unknown") # With default
|
||||
"""
|
||||
# Handle lists by accessing the first element
|
||||
if isinstance(obj, list) and obj:
|
||||
obj = obj[0]
|
||||
|
||||
if isinstance(obj, dict):
|
||||
# Direct lookup first
|
||||
val = obj.get(field, default)
|
||||
if val is not None:
|
||||
return val
|
||||
# Fallback aliases for common fields
|
||||
if field == "path":
|
||||
for alt in ("file_path", "target", "filepath", "file"):
|
||||
v = obj.get(alt)
|
||||
if v:
|
||||
return v
|
||||
if field == "hash":
|
||||
for alt in ("file_hash", "hash_hex"):
|
||||
v = obj.get(alt)
|
||||
if v:
|
||||
return v
|
||||
if field == "store":
|
||||
for alt in ("storage", "storage_source", "origin"):
|
||||
v = obj.get(alt)
|
||||
if v:
|
||||
return v
|
||||
return default
|
||||
else:
|
||||
# Try direct attribute access first
|
||||
value = getattr(obj, field, None)
|
||||
if value is not None:
|
||||
return value
|
||||
|
||||
# Attribute fallback aliases for common fields
|
||||
if field == "path":
|
||||
for alt in ("file_path", "target", "filepath", "file", "url"):
|
||||
v = getattr(obj, alt, None)
|
||||
if v:
|
||||
return v
|
||||
if field == "hash":
|
||||
for alt in ("file_hash", "hash_hex"):
|
||||
v = getattr(obj, alt, None)
|
||||
if v:
|
||||
return v
|
||||
if field == "store":
|
||||
for alt in ("storage", "storage_source", "origin"):
|
||||
v = getattr(obj, alt, None)
|
||||
if v:
|
||||
return v
|
||||
|
||||
# For PipeObjects, also check the extra field
|
||||
if hasattr(obj, 'extra') and isinstance(obj.extra, dict):
|
||||
return obj.extra.get(field, default)
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def should_show_help(args: Sequence[str]) -> bool:
|
||||
"""Check if help flag was passed in arguments.
|
||||
|
||||
Consolidates repeated pattern of checking for help flags across cmdlets.
|
||||
|
||||
Args:
|
||||
args: Command arguments to check
|
||||
|
||||
Returns:
|
||||
True if any help flag is present (-?, /?, --help, -h, help, --cmdlet)
|
||||
|
||||
Examples:
|
||||
if should_show_help(args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
"""
|
||||
try:
|
||||
return any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def looks_like_hash(candidate: Optional[str]) -> bool:
|
||||
"""Check if a string looks like a SHA256 hash (64 hex chars).
|
||||
|
||||
@@ -609,8 +804,8 @@ def looks_like_hash(candidate: Optional[str]) -> bool:
|
||||
def pipeline_item_local_path(item: Any) -> Optional[str]:
|
||||
"""Extract local file path from a pipeline item.
|
||||
|
||||
Supports both dataclass objects with .target attribute and dicts.
|
||||
Returns None for HTTP/HTTPS URLs.
|
||||
Supports both dataclass objects with .path attribute and dicts.
|
||||
Returns None for HTTP/HTTPS url.
|
||||
|
||||
Args:
|
||||
item: Pipeline item (PipelineItem dataclass, dict, or other)
|
||||
@@ -618,15 +813,15 @@ def pipeline_item_local_path(item: Any) -> Optional[str]:
|
||||
Returns:
|
||||
Local file path string, or None if item is not a local file
|
||||
"""
|
||||
target: Optional[str] = None
|
||||
if hasattr(item, "target"):
|
||||
target = getattr(item, "target", None)
|
||||
path_value: Optional[str] = None
|
||||
if hasattr(item, "path"):
|
||||
path_value = getattr(item, "path", None)
|
||||
elif isinstance(item, dict):
|
||||
raw = item.get("target") or item.get("path") or item.get("url")
|
||||
target = str(raw) if raw is not None else None
|
||||
if not isinstance(target, str):
|
||||
raw = item.get("path") or item.get("url")
|
||||
path_value = str(raw) if raw is not None else None
|
||||
if not isinstance(path_value, str):
|
||||
return None
|
||||
text = target.strip()
|
||||
text = path_value.strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.lower().startswith(("http://", "https://")):
|
||||
@@ -686,22 +881,60 @@ def collect_relationship_labels(payload: Any, label_stack: List[str] | None = No
|
||||
|
||||
def parse_tag_arguments(arguments: Sequence[str]) -> List[str]:
|
||||
"""Parse tag arguments from command line tokens.
|
||||
|
||||
Handles both space-separated and comma-separated tags.
|
||||
Example: parse_tag_arguments(["tag1,tag2", "tag3"]) -> ["tag1", "tag2", "tag3"]
|
||||
|
||||
|
||||
- Supports comma-separated tags.
|
||||
- Supports pipe namespace shorthand: "artist:A|B|C" -> artist:A, artist:B, artist:C.
|
||||
|
||||
Args:
|
||||
arguments: Sequence of argument strings
|
||||
|
||||
|
||||
Returns:
|
||||
List of normalized tag strings (empty strings filtered out)
|
||||
"""
|
||||
|
||||
def _expand_pipe_namespace(text: str) -> List[str]:
|
||||
parts = text.split('|')
|
||||
expanded: List[str] = []
|
||||
last_ns: Optional[str] = None
|
||||
for part in parts:
|
||||
segment = part.strip()
|
||||
if not segment:
|
||||
continue
|
||||
if ':' in segment:
|
||||
ns, val = segment.split(':', 1)
|
||||
ns = ns.strip()
|
||||
val = val.strip()
|
||||
last_ns = ns or last_ns
|
||||
if last_ns and val:
|
||||
expanded.append(f"{last_ns}:{val}")
|
||||
elif ns or val:
|
||||
expanded.append(f"{ns}:{val}".strip(':'))
|
||||
else:
|
||||
if last_ns:
|
||||
expanded.append(f"{last_ns}:{segment}")
|
||||
else:
|
||||
expanded.append(segment)
|
||||
return expanded
|
||||
|
||||
tags: List[str] = []
|
||||
for argument in arguments:
|
||||
for token in argument.split(','):
|
||||
text = token.strip()
|
||||
if text:
|
||||
tags.append(text)
|
||||
if not text:
|
||||
continue
|
||||
# Expand namespace shorthand with pipes
|
||||
pipe_expanded = _expand_pipe_namespace(text)
|
||||
for entry in pipe_expanded:
|
||||
candidate = entry.strip()
|
||||
if not candidate:
|
||||
continue
|
||||
if ':' in candidate:
|
||||
ns, val = candidate.split(':', 1)
|
||||
ns = ns.strip()
|
||||
val = val.strip()
|
||||
candidate = f"{ns}:{val}" if ns or val else ""
|
||||
if candidate:
|
||||
tags.append(candidate)
|
||||
return tags
|
||||
|
||||
|
||||
@@ -944,7 +1177,7 @@ def create_pipe_object_result(
|
||||
result = {
|
||||
'source': source,
|
||||
'id': identifier,
|
||||
'file_path': file_path,
|
||||
'path': file_path,
|
||||
'action': f'cmdlet:{cmdlet_name}', # Format: cmdlet:cmdlet_name
|
||||
}
|
||||
|
||||
@@ -952,6 +1185,7 @@ def create_pipe_object_result(
|
||||
result['title'] = title
|
||||
if file_hash:
|
||||
result['file_hash'] = file_hash
|
||||
result['hash'] = file_hash
|
||||
if is_temp:
|
||||
result['is_temp'] = True
|
||||
if parent_hash:
|
||||
@@ -959,6 +1193,13 @@ def create_pipe_object_result(
|
||||
if tags:
|
||||
result['tags'] = tags
|
||||
|
||||
# Canonical store field: use source for compatibility
|
||||
try:
|
||||
if source:
|
||||
result['store'] = source
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add any extra fields
|
||||
result.update(extra)
|
||||
|
||||
@@ -996,13 +1237,13 @@ def get_pipe_object_path(pipe_object: Any) -> Optional[str]:
|
||||
"""Extract file path from PipeObject, dict, or pipeline-friendly object."""
|
||||
if pipe_object is None:
|
||||
return None
|
||||
for attr in ('file_path', 'path', 'target'):
|
||||
for attr in ('path', 'target'):
|
||||
if hasattr(pipe_object, attr):
|
||||
value = getattr(pipe_object, attr)
|
||||
if value:
|
||||
return value
|
||||
if isinstance(pipe_object, dict):
|
||||
for key in ('file_path', 'path', 'target'):
|
||||
for key in ('path', 'target'):
|
||||
value = pipe_object.get(key)
|
||||
if value:
|
||||
return value
|
||||
@@ -1209,40 +1450,40 @@ def extract_title_from_result(result: Any) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def extract_known_urls_from_result(result: Any) -> list[str]:
|
||||
urls: list[str] = []
|
||||
def extract_url_from_result(result: Any) -> list[str]:
|
||||
url: list[str] = []
|
||||
|
||||
def _extend(candidate: Any) -> None:
|
||||
if not candidate:
|
||||
return
|
||||
if isinstance(candidate, list):
|
||||
urls.extend(candidate)
|
||||
url.extend(candidate)
|
||||
elif isinstance(candidate, str):
|
||||
urls.append(candidate)
|
||||
url.append(candidate)
|
||||
|
||||
if isinstance(result, models.PipeObject):
|
||||
_extend(result.extra.get('known_urls'))
|
||||
_extend(result.extra.get('url'))
|
||||
_extend(result.extra.get('url')) # Also check singular url
|
||||
if isinstance(result.metadata, dict):
|
||||
_extend(result.metadata.get('known_urls'))
|
||||
_extend(result.metadata.get('urls'))
|
||||
_extend(result.metadata.get('url'))
|
||||
elif hasattr(result, 'known_urls') or hasattr(result, 'urls'):
|
||||
# Handle objects with known_urls/urls attribute
|
||||
_extend(getattr(result, 'known_urls', None))
|
||||
_extend(getattr(result, 'urls', None))
|
||||
_extend(result.metadata.get('url'))
|
||||
_extend(result.metadata.get('url'))
|
||||
elif hasattr(result, 'url') or hasattr(result, 'url'):
|
||||
# Handle objects with url/url attribute
|
||||
_extend(getattr(result, 'url', None))
|
||||
_extend(getattr(result, 'url', None))
|
||||
|
||||
if isinstance(result, dict):
|
||||
_extend(result.get('known_urls'))
|
||||
_extend(result.get('urls'))
|
||||
_extend(result.get('url'))
|
||||
_extend(result.get('url'))
|
||||
_extend(result.get('url'))
|
||||
extra = result.get('extra')
|
||||
if isinstance(extra, dict):
|
||||
_extend(extra.get('known_urls'))
|
||||
_extend(extra.get('urls'))
|
||||
_extend(extra.get('url'))
|
||||
_extend(extra.get('url'))
|
||||
_extend(extra.get('url'))
|
||||
|
||||
return merge_sequences(urls, case_sensitive=True)
|
||||
return merge_sequences(url, case_sensitive=True)
|
||||
|
||||
|
||||
def extract_relationships(result: Any) -> Optional[Dict[str, Any]]:
|
||||
@@ -1272,3 +1513,248 @@ def extract_duration(result: Any) -> Optional[float]:
|
||||
return float(duration)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def coerce_to_pipe_object(value: Any, default_path: Optional[str] = None) -> models.PipeObject:
|
||||
"""Normalize any incoming result to a PipeObject for single-source-of-truth state.
|
||||
|
||||
Uses hash+store canonical pattern.
|
||||
"""
|
||||
# Debug: Print ResultItem details if coming from search_file.py
|
||||
try:
|
||||
from helper.logger import is_debug_enabled, debug
|
||||
if is_debug_enabled() and hasattr(value, '__class__') and value.__class__.__name__ == 'ResultItem':
|
||||
debug("[ResultItem -> PipeObject conversion]")
|
||||
debug(f" origin={getattr(value, 'origin', None)}")
|
||||
debug(f" title={getattr(value, 'title', None)}")
|
||||
debug(f" target={getattr(value, 'target', None)}")
|
||||
debug(f" hash_hex={getattr(value, 'hash_hex', None)}")
|
||||
debug(f" media_kind={getattr(value, 'media_kind', None)}")
|
||||
debug(f" tags={getattr(value, 'tags', None)}")
|
||||
debug(f" tag_summary={getattr(value, 'tag_summary', None)}")
|
||||
debug(f" size_bytes={getattr(value, 'size_bytes', None)}")
|
||||
debug(f" duration_seconds={getattr(value, 'duration_seconds', None)}")
|
||||
debug(f" relationships={getattr(value, 'relationships', None)}")
|
||||
debug(f" url={getattr(value, 'url', None)}")
|
||||
debug(f" full_metadata keys={list(getattr(value, 'full_metadata', {}).keys()) if hasattr(value, 'full_metadata') and value.full_metadata else []}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if isinstance(value, models.PipeObject):
|
||||
return value
|
||||
|
||||
known_keys = {
|
||||
"hash", "store", "tags", "title", "url", "source_url", "duration", "metadata",
|
||||
"warnings", "path", "relationships", "is_temp", "action", "parent_hash",
|
||||
}
|
||||
|
||||
# Convert ResultItem to dict to preserve all attributes
|
||||
if hasattr(value, 'to_dict'):
|
||||
value = value.to_dict()
|
||||
|
||||
if isinstance(value, dict):
|
||||
# Extract hash and store (canonical identifiers)
|
||||
hash_val = value.get("hash") or value.get("file_hash")
|
||||
# Recognize multiple possible store naming conventions (store, origin, storage, storage_source)
|
||||
store_val = value.get("store") or value.get("origin") or value.get("storage") or value.get("storage_source") or "PATH"
|
||||
# If the store value is embedded under extra, also detect it
|
||||
if not store_val or store_val in ("local", "PATH"):
|
||||
extra_store = None
|
||||
try:
|
||||
extra_store = value.get("extra", {}).get("store") or value.get("extra", {}).get("storage") or value.get("extra", {}).get("storage_source")
|
||||
except Exception:
|
||||
extra_store = None
|
||||
if extra_store:
|
||||
store_val = extra_store
|
||||
|
||||
# If no hash, try to compute from path or use placeholder
|
||||
if not hash_val:
|
||||
path_val = value.get("path")
|
||||
if path_val:
|
||||
try:
|
||||
from helper.utils import sha256_file
|
||||
from pathlib import Path
|
||||
hash_val = sha256_file(Path(path_val))
|
||||
except Exception:
|
||||
hash_val = "unknown"
|
||||
else:
|
||||
hash_val = "unknown"
|
||||
|
||||
# Extract title from filename if not provided
|
||||
title_val = value.get("title")
|
||||
if not title_val:
|
||||
path_val = value.get("path")
|
||||
if path_val:
|
||||
try:
|
||||
from pathlib import Path
|
||||
title_val = Path(path_val).stem
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
extra = {k: v for k, v in value.items() if k not in known_keys}
|
||||
|
||||
# Extract URL: prefer direct url field, then url list
|
||||
url_val = value.get("url")
|
||||
if not url_val:
|
||||
url = value.get("url") or value.get("url") or []
|
||||
if url and isinstance(url, list) and len(url) > 0:
|
||||
url_val = url[0]
|
||||
# Preserve url in extra if multiple url exist
|
||||
if url and len(url) > 1:
|
||||
extra["url"] = url
|
||||
|
||||
# Extract relationships
|
||||
rels = value.get("relationships") or {}
|
||||
|
||||
# Consolidate tags: prefer tags_set over tags, tag_summary
|
||||
tags_val = []
|
||||
if "tags_set" in value and value["tags_set"]:
|
||||
tags_val = list(value["tags_set"])
|
||||
elif "tags" in value and isinstance(value["tags"], (list, set)):
|
||||
tags_val = list(value["tags"])
|
||||
elif "tag" in value:
|
||||
# Single tag string or list
|
||||
if isinstance(value["tag"], list):
|
||||
tags_val = value["tag"] # Already a list
|
||||
else:
|
||||
tags_val = [value["tag"]] # Wrap single string in list
|
||||
|
||||
# Consolidate path: prefer explicit path key, but NOT target if it's a URL
|
||||
path_val = value.get("path")
|
||||
# Only use target as path if it's not a URL (url should stay in url field)
|
||||
if not path_val and "target" in value:
|
||||
target = value["target"]
|
||||
if target and not (isinstance(target, str) and (target.startswith("http://") or target.startswith("https://"))):
|
||||
path_val = target
|
||||
|
||||
# If the path value is actually a URL, move it to url_val and clear path_val
|
||||
try:
|
||||
if isinstance(path_val, str) and (path_val.startswith("http://") or path_val.startswith("https://")):
|
||||
# Prefer existing url_val if present, otherwise move path_val into url_val
|
||||
if not url_val:
|
||||
url_val = path_val
|
||||
path_val = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extract media_kind if available
|
||||
if "media_kind" in value:
|
||||
extra["media_kind"] = value["media_kind"]
|
||||
|
||||
pipe_obj = models.PipeObject(
|
||||
hash=hash_val,
|
||||
store=store_val,
|
||||
tags=tags_val,
|
||||
title=title_val,
|
||||
url=url_val,
|
||||
source_url=value.get("source_url"),
|
||||
duration=value.get("duration") or value.get("duration_seconds"),
|
||||
metadata=value.get("metadata") or value.get("full_metadata") or {},
|
||||
warnings=list(value.get("warnings") or []),
|
||||
path=path_val,
|
||||
relationships=rels,
|
||||
is_temp=bool(value.get("is_temp", False)),
|
||||
action=value.get("action"),
|
||||
parent_hash=value.get("parent_hash") or value.get("parent_id"),
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
# Debug: Print formatted table
|
||||
pipe_obj.debug_table()
|
||||
|
||||
return pipe_obj
|
||||
|
||||
# Fallback: build from path argument or bare value
|
||||
hash_val = "unknown"
|
||||
path_val = default_path or getattr(value, "path", None)
|
||||
title_val = None
|
||||
|
||||
if path_val and path_val != "unknown":
|
||||
try:
|
||||
from helper.utils import sha256_file
|
||||
from pathlib import Path
|
||||
path_obj = Path(path_val)
|
||||
hash_val = sha256_file(path_obj)
|
||||
# Extract title from filename (without extension)
|
||||
title_val = path_obj.stem
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# When coming from path argument, store should be "PATH" (file path, not a backend)
|
||||
store_val = "PATH"
|
||||
|
||||
pipe_obj = models.PipeObject(
|
||||
hash=hash_val,
|
||||
store=store_val,
|
||||
path=str(path_val) if path_val and path_val != "unknown" else None,
|
||||
title=title_val,
|
||||
tags=[],
|
||||
extra={},
|
||||
)
|
||||
|
||||
# Debug: Print formatted table
|
||||
pipe_obj.debug_table()
|
||||
|
||||
return pipe_obj
|
||||
|
||||
|
||||
def register_url_with_local_library(pipe_obj: models.PipeObject, config: Dict[str, Any]) -> bool:
|
||||
"""Register url with a file in the local library database.
|
||||
|
||||
This is called automatically by download cmdlets to ensure url are persisted
|
||||
without requiring a separate add-url step in the pipeline.
|
||||
|
||||
Args:
|
||||
pipe_obj: PipeObject with path and url
|
||||
config: Config dict containing local library path
|
||||
|
||||
Returns:
|
||||
True if url were registered, False otherwise
|
||||
"""
|
||||
|
||||
try:
|
||||
from config import get_local_storage_path
|
||||
from helper.folder_store import FolderDB
|
||||
|
||||
file_path = get_field(pipe_obj, "path")
|
||||
url_field = get_field(pipe_obj, "url", [])
|
||||
urls: List[str] = []
|
||||
if isinstance(url_field, str):
|
||||
urls = [u.strip() for u in url_field.split(",") if u.strip()]
|
||||
elif isinstance(url_field, (list, tuple)):
|
||||
urls = [u for u in url_field if isinstance(u, str) and u.strip()]
|
||||
|
||||
if not file_path or not urls:
|
||||
return False
|
||||
|
||||
path_obj = Path(file_path)
|
||||
if not path_obj.exists():
|
||||
return False
|
||||
|
||||
storage_path = get_local_storage_path(config)
|
||||
if not storage_path:
|
||||
return False
|
||||
|
||||
with FolderDB(storage_path) as db:
|
||||
file_hash = db.get_file_hash(path_obj)
|
||||
if not file_hash:
|
||||
return False
|
||||
metadata = db.get_metadata(file_hash) or {}
|
||||
existing_url = metadata.get("url") or []
|
||||
|
||||
# Add any new url
|
||||
changed = False
|
||||
for u in urls:
|
||||
if u not in existing_url:
|
||||
existing_url.append(u)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
metadata["url"] = existing_url
|
||||
db.save_metadata(path_obj, metadata)
|
||||
return True
|
||||
|
||||
return True # url already existed
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
1818
cmdlets/add_file.py
1818
cmdlets/add_file.py
File diff suppressed because it is too large
Load Diff
@@ -7,19 +7,19 @@ from . import register
|
||||
import models
|
||||
import pipeline as ctx
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash, should_show_help
|
||||
from helper.logger import log
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="add-note",
|
||||
summary="Add or set a note on a Hydrus file.",
|
||||
usage="add-note [-hash <sha256>] <name> <text>",
|
||||
args=[
|
||||
arg=[
|
||||
CmdletArg("hash", type="string", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."),
|
||||
CmdletArg("name", type="string", required=True, description="The note name/key to set (e.g. 'comment', 'source', etc.)."),
|
||||
CmdletArg("text", type="string", required=True, description="The note text/content to store.", variadic=True),
|
||||
],
|
||||
details=[
|
||||
detail=[
|
||||
"- Notes are stored in the 'my notes' service by default.",
|
||||
],
|
||||
)
|
||||
@@ -28,12 +28,9 @@ CMDLET = Cmdlet(
|
||||
@register(["add-note", "set-note", "add_note"]) # aliases
|
||||
def add(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Help
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
if should_show_help(args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
from ._shared import parse_cmdlet_args
|
||||
parsed = parse_cmdlet_args(args, CMDLET)
|
||||
|
||||
@@ -14,20 +14,20 @@ from . import register
|
||||
import models
|
||||
import pipeline as ctx
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args, normalize_result_input
|
||||
from helper.local_library import read_sidecar, find_sidecar
|
||||
from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args, normalize_result_input, should_show_help, get_field
|
||||
from helper.folder_store import read_sidecar, find_sidecar
|
||||
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="add-relationship",
|
||||
summary="Associate file relationships (king/alt/related) in Hydrus based on relationship tags in sidecar.",
|
||||
usage="@1-3 | add-relationship -king @4 OR add-relationship -path <file> OR @1,@2,@3 | add-relationship",
|
||||
args=[
|
||||
arg=[
|
||||
CmdletArg("path", type="string", description="Specify the local file path (if not piping a result)."),
|
||||
CmdletArg("-king", type="string", description="Explicitly set the king hash/file for relationships (e.g., -king @4 or -king hash)"),
|
||||
CmdletArg("-type", type="string", description="Relationship type for piped items (default: 'alt', options: 'king', 'alt', 'related')"),
|
||||
],
|
||||
details=[
|
||||
detail=[
|
||||
"- Mode 1: Pipe multiple items, first becomes king, rest become alts (default)",
|
||||
"- Mode 2: Use -king to explicitly set which item/hash is the king: @1-3 | add-relationship -king @4",
|
||||
"- Mode 3: Read relationships from sidecar (format: 'relationship: hash(king)<HASH>,hash(alt)<HASH>...')",
|
||||
@@ -108,13 +108,11 @@ def _resolve_king_reference(king_arg: str) -> Optional[str]:
|
||||
item = items[index]
|
||||
|
||||
# Try to extract hash from the item (could be dict or object)
|
||||
item_hash = None
|
||||
if isinstance(item, dict):
|
||||
# Dictionary: try common hash field names
|
||||
item_hash = item.get('hash_hex') or item.get('hash') or item.get('file_hash')
|
||||
else:
|
||||
# Object: use getattr
|
||||
item_hash = getattr(item, 'hash_hex', None) or getattr(item, 'hash', None)
|
||||
item_hash = (
|
||||
get_field(item, 'hash_hex')
|
||||
or get_field(item, 'hash')
|
||||
or get_field(item, 'file_hash')
|
||||
)
|
||||
|
||||
if item_hash:
|
||||
normalized = _normalise_hash_hex(item_hash)
|
||||
@@ -122,13 +120,11 @@ def _resolve_king_reference(king_arg: str) -> Optional[str]:
|
||||
return normalized
|
||||
|
||||
# If no hash, try to get file path (for local storage)
|
||||
file_path = None
|
||||
if isinstance(item, dict):
|
||||
# Dictionary: try common path field names
|
||||
file_path = item.get('file_path') or item.get('path') or item.get('target')
|
||||
else:
|
||||
# Object: use getattr
|
||||
file_path = getattr(item, 'file_path', None) or getattr(item, 'path', None) or getattr(item, 'target', None)
|
||||
file_path = (
|
||||
get_field(item, 'file_path')
|
||||
or get_field(item, 'path')
|
||||
or get_field(item, 'target')
|
||||
)
|
||||
|
||||
if file_path:
|
||||
return str(file_path)
|
||||
@@ -199,12 +195,9 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
Returns 0 on success, non-zero on failure.
|
||||
"""
|
||||
# Help
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in _args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
if should_show_help(_args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
# Parse arguments using CMDLET spec
|
||||
parsed = parse_cmdlet_args(_args, CMDLET)
|
||||
@@ -235,7 +228,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
items_to_process = [{"file_path": arg_path}]
|
||||
|
||||
# Import local storage utilities
|
||||
from helper.local_library import LocalLibrarySearchOptimizer
|
||||
from helper.folder_store import LocalLibrarySearchOptimizer
|
||||
from config import get_local_storage_path
|
||||
|
||||
local_storage_path = get_local_storage_path(config) if config else None
|
||||
|
||||
567
cmdlets/add_tag.py
Normal file
567
cmdlets/add_tag.py
Normal file
@@ -0,0 +1,567 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Sequence, Optional
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from helper.logger import log
|
||||
|
||||
import models
|
||||
import pipeline as ctx
|
||||
from ._shared import normalize_result_input, filter_results_by_temp
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from helper.folder_store import write_sidecar, FolderDB
|
||||
from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, parse_tag_arguments, expand_tag_groups, parse_cmdlet_args, collapse_namespace_tags, should_show_help, get_field
|
||||
from config import get_local_storage_path
|
||||
|
||||
|
||||
|
||||
class Add_Tag(Cmdlet):
|
||||
"""Class-based add-tag cmdlet with Cmdlet metadata inheritance."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
name="add-tag",
|
||||
summary="Add a tag to a Hydrus file or write it to a local .tags sidecar.",
|
||||
usage="add-tag [-hash <sha256>] [-store <backend>] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]",
|
||||
arg=[
|
||||
SharedArgs.HASH,
|
||||
SharedArgs.STORE,
|
||||
CmdletArg("-duplicate", type="string", description="Copy existing tag values to new namespaces. Formats: title:album,artist (explicit) or title,album,artist (inferred)"),
|
||||
CmdletArg("-list", type="string", description="Load predefined tag lists from adjective.json. Comma-separated list names (e.g., -list philosophy,occult)."),
|
||||
CmdletArg("--all", type="flag", description="Include temporary files in tagging (by default, only tags non-temporary files)."),
|
||||
CmdletArg("tags", type="string", required=False, description="One or more tags to add. Comma- or space-separated. Can also use {list_name} syntax. If omitted, uses tags from pipeline payload.", variadic=True),
|
||||
],
|
||||
detail=[
|
||||
"- By default, only tags non-temporary files (from pipelines). Use --all to tag everything.",
|
||||
"- Without -hash and when the selection is a local file, tags are written to <file>.tags.",
|
||||
"- With a Hydrus hash, tags are sent to the 'my tags' service.",
|
||||
"- Multiple tags can be comma-separated or space-separated.",
|
||||
"- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult",
|
||||
"- Tags can also reference lists with curly braces: add-tag {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).",
|
||||
"- You can also pass the target hash as a tag token: hash:<sha256>. This overrides -hash and is removed from the tag list.",
|
||||
],
|
||||
exec=self.run,
|
||||
)
|
||||
self.register()
|
||||
|
||||
@staticmethod
|
||||
def _extract_title_tag(tags: List[str]) -> Optional[str]:
|
||||
"""Return the value of the first title: tag if present."""
|
||||
for tag in tags:
|
||||
if isinstance(tag, str) and tag.lower().startswith("title:"):
|
||||
value = tag.split(":", 1)[1].strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _apply_title_to_result(res: Any, title_value: Optional[str]) -> None:
|
||||
"""Update result object/dict title fields and columns in-place."""
|
||||
if not title_value:
|
||||
return
|
||||
if isinstance(res, models.PipeObject):
|
||||
res.title = title_value
|
||||
if hasattr(res, "columns") and isinstance(res.columns, list) and res.columns:
|
||||
label, *_ = res.columns[0]
|
||||
if str(label).lower() == "title":
|
||||
res.columns[0] = (res.columns[0][0], title_value)
|
||||
elif isinstance(res, dict):
|
||||
res["title"] = title_value
|
||||
cols = res.get("columns")
|
||||
if isinstance(cols, list):
|
||||
updated = []
|
||||
changed = False
|
||||
for col in cols:
|
||||
if isinstance(col, tuple) and len(col) == 2:
|
||||
label, val = col
|
||||
if str(label).lower() == "title":
|
||||
updated.append((label, title_value))
|
||||
changed = True
|
||||
else:
|
||||
updated.append(col)
|
||||
else:
|
||||
updated.append(col)
|
||||
if changed:
|
||||
res["columns"] = updated
|
||||
|
||||
@staticmethod
|
||||
def _matches_target(item: Any, hydrus_hash: Optional[str], file_hash: Optional[str], file_path: Optional[str]) -> bool:
|
||||
"""Determine whether a result item refers to the given hash/path target."""
|
||||
hydrus_hash_l = hydrus_hash.lower() if hydrus_hash else None
|
||||
file_hash_l = file_hash.lower() if file_hash else None
|
||||
file_path_l = file_path.lower() if file_path else None
|
||||
|
||||
def norm(val: Any) -> Optional[str]:
|
||||
return str(val).lower() if val is not None else None
|
||||
|
||||
hash_fields = ["hydrus_hash", "hash", "hash_hex", "file_hash"]
|
||||
path_fields = ["path", "file_path", "target"]
|
||||
|
||||
if isinstance(item, dict):
|
||||
hashes = [norm(item.get(field)) for field in hash_fields]
|
||||
paths = [norm(item.get(field)) for field in path_fields]
|
||||
else:
|
||||
hashes = [norm(get_field(item, field)) for field in hash_fields]
|
||||
paths = [norm(get_field(item, field)) for field in path_fields]
|
||||
|
||||
if hydrus_hash_l and hydrus_hash_l in hashes:
|
||||
return True
|
||||
if file_hash_l and file_hash_l in hashes:
|
||||
return True
|
||||
if file_path_l and file_path_l in paths:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _update_item_title_fields(item: Any, new_title: str) -> None:
|
||||
"""Mutate an item to reflect a new title in plain fields and columns."""
|
||||
if isinstance(item, models.PipeObject):
|
||||
item.title = new_title
|
||||
if hasattr(item, "columns") and isinstance(item.columns, list) and item.columns:
|
||||
label, *_ = item.columns[0]
|
||||
if str(label).lower() == "title":
|
||||
item.columns[0] = (label, new_title)
|
||||
elif isinstance(item, dict):
|
||||
item["title"] = new_title
|
||||
cols = item.get("columns")
|
||||
if isinstance(cols, list):
|
||||
updated_cols = []
|
||||
changed = False
|
||||
for col in cols:
|
||||
if isinstance(col, tuple) and len(col) == 2:
|
||||
label, val = col
|
||||
if str(label).lower() == "title":
|
||||
updated_cols.append((label, new_title))
|
||||
changed = True
|
||||
else:
|
||||
updated_cols.append(col)
|
||||
else:
|
||||
updated_cols.append(col)
|
||||
if changed:
|
||||
item["columns"] = updated_cols
|
||||
|
||||
def _refresh_result_table_title(self, new_title: str, hydrus_hash: Optional[str], file_hash: Optional[str], file_path: Optional[str]) -> None:
|
||||
"""Refresh the cached result table with an updated title and redisplay it."""
|
||||
try:
|
||||
last_table = ctx.get_last_result_table()
|
||||
items = ctx.get_last_result_items()
|
||||
if not last_table or not items:
|
||||
return
|
||||
|
||||
updated_items = []
|
||||
match_found = False
|
||||
for item in items:
|
||||
try:
|
||||
if self._matches_target(item, hydrus_hash, file_hash, file_path):
|
||||
self._update_item_title_fields(item, new_title)
|
||||
match_found = True
|
||||
except Exception:
|
||||
pass
|
||||
updated_items.append(item)
|
||||
if not match_found:
|
||||
return
|
||||
|
||||
from result_table import ResultTable # Local import to avoid circular dependency
|
||||
|
||||
new_table = last_table.copy_with_title(getattr(last_table, "title", ""))
|
||||
|
||||
for item in updated_items:
|
||||
new_table.add_result(item)
|
||||
|
||||
ctx.set_last_result_table_overlay(new_table, updated_items)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _refresh_tags_view(self, res: Any, hydrus_hash: Optional[str], file_hash: Optional[str], file_path: Optional[str], config: Dict[str, Any]) -> None:
|
||||
"""Refresh tag display via get-tag. Prefer current subject; fall back to direct hash refresh."""
|
||||
try:
|
||||
from cmdlets import get_tag as get_tag_cmd # type: ignore
|
||||
except Exception:
|
||||
return
|
||||
|
||||
target_hash = hydrus_hash or file_hash
|
||||
refresh_args: List[str] = []
|
||||
if target_hash:
|
||||
refresh_args = ["-hash", target_hash, "-store", target_hash]
|
||||
|
||||
try:
|
||||
subject = ctx.get_last_result_subject()
|
||||
if subject and self._matches_target(subject, hydrus_hash, file_hash, file_path):
|
||||
get_tag_cmd._run(subject, refresh_args, config)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if target_hash:
|
||||
try:
|
||||
get_tag_cmd._run(res, refresh_args, config)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Add a tag to a file with smart filtering for pipeline results."""
|
||||
if should_show_help(args):
|
||||
log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}")
|
||||
return 0
|
||||
|
||||
parsed = parse_cmdlet_args(args, self)
|
||||
|
||||
# Check for --all flag
|
||||
include_temp = parsed.get("all", False)
|
||||
|
||||
# Get explicit -hash and -store overrides from CLI
|
||||
hash_override = normalize_hash(parsed.get("hash"))
|
||||
store_override = parsed.get("store") or parsed.get("storage")
|
||||
|
||||
# Normalize input to list
|
||||
results = normalize_result_input(result)
|
||||
|
||||
# If no piped results but we have -hash flag, create a minimal synthetic result
|
||||
if not results and hash_override:
|
||||
results = [{"hash": hash_override, "is_temp": False}]
|
||||
if store_override:
|
||||
results[0]["store"] = store_override
|
||||
|
||||
# Filter by temp status (unless --all is set)
|
||||
if not include_temp:
|
||||
results = filter_results_by_temp(results, include_temp=False)
|
||||
|
||||
if not results:
|
||||
log("No valid files to tag (all results were temporary; use --all to include temporary files)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Get tags from arguments (or fallback to pipeline payload)
|
||||
raw_tags = parsed.get("tags", [])
|
||||
if isinstance(raw_tags, str):
|
||||
raw_tags = [raw_tags]
|
||||
|
||||
# Fallback: if no tags provided explicitly, try to pull from first result payload
|
||||
if not raw_tags and results:
|
||||
first = results[0]
|
||||
payload_tags = None
|
||||
# Try multiple tag lookup strategies in order
|
||||
tag_lookups = [
|
||||
lambda x: x.extra.get("tags") if isinstance(x, models.PipeObject) and isinstance(x.extra, dict) else None,
|
||||
lambda x: x.get("tags") if isinstance(x, dict) else None,
|
||||
lambda x: x.get("extra", {}).get("tags") if isinstance(x, dict) and isinstance(x.get("extra"), dict) else None,
|
||||
lambda x: getattr(x, "tags", None),
|
||||
]
|
||||
for lookup in tag_lookups:
|
||||
try:
|
||||
payload_tags = lookup(first)
|
||||
if payload_tags:
|
||||
break
|
||||
except (AttributeError, TypeError, KeyError):
|
||||
continue
|
||||
if payload_tags:
|
||||
if isinstance(payload_tags, str):
|
||||
raw_tags = [payload_tags]
|
||||
elif isinstance(payload_tags, list):
|
||||
raw_tags = payload_tags
|
||||
|
||||
# Handle -list argument (convert to {list} syntax)
|
||||
list_arg = parsed.get("list")
|
||||
if list_arg:
|
||||
for l in list_arg.split(','):
|
||||
l = l.strip()
|
||||
if l:
|
||||
raw_tags.append(f"{{{l}}}")
|
||||
|
||||
# Parse and expand tags
|
||||
tags_to_add = parse_tag_arguments(raw_tags)
|
||||
tags_to_add = expand_tag_groups(tags_to_add)
|
||||
|
||||
# Allow hash override via namespaced token (e.g., "hash:abcdef...")
|
||||
extracted_hash = None
|
||||
filtered_tags: List[str] = []
|
||||
for tag in tags_to_add:
|
||||
if isinstance(tag, str) and tag.lower().startswith("hash:"):
|
||||
_, _, hash_val = tag.partition(":")
|
||||
if hash_val:
|
||||
extracted_hash = normalize_hash(hash_val.strip())
|
||||
continue
|
||||
filtered_tags.append(tag)
|
||||
tags_to_add = filtered_tags
|
||||
|
||||
if not tags_to_add:
|
||||
log("No tags provided to add", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
def _find_library_root(path_obj: Path) -> Optional[Path]:
|
||||
candidates = []
|
||||
cfg_root = get_local_storage_path(config) if config else None
|
||||
if cfg_root:
|
||||
try:
|
||||
candidates.append(Path(cfg_root).expanduser())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
for candidate in candidates:
|
||||
if (candidate / "medios-macina.db").exists():
|
||||
return candidate
|
||||
for parent in [path_obj] + list(path_obj.parents):
|
||||
if (parent / "medios-macina.db").exists():
|
||||
return parent
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# Get other flags
|
||||
duplicate_arg = parsed.get("duplicate")
|
||||
|
||||
if not tags_to_add and not duplicate_arg:
|
||||
# Write sidecar files with the tags that are already in the result dicts
|
||||
sidecar_count = 0
|
||||
for res in results:
|
||||
# Handle both dict and PipeObject formats
|
||||
file_path = None
|
||||
tags = []
|
||||
file_hash = ""
|
||||
# Use canonical field access with get_field for both dict and objects
|
||||
file_path = get_field(res, "path")
|
||||
# Try tags from top-level 'tags' or from 'extra.tags'
|
||||
tags = get_field(res, "tags") or (get_field(res, "extra") or {}).get("tags", [])
|
||||
file_hash = get_field(res, "hash") or get_field(res, "file_hash") or get_field(res, "hash_hex") or ""
|
||||
if not file_path:
|
||||
log(f"[add_tag] Warning: Result has no path, skipping", file=sys.stderr)
|
||||
ctx.emit(res)
|
||||
continue
|
||||
if tags:
|
||||
# Write sidecar file for this file with its tags
|
||||
try:
|
||||
sidecar_path = write_sidecar(Path(file_path), tags, [], file_hash)
|
||||
log(f"[add_tag] Wrote {len(tags)} tag(s) to sidecar: {sidecar_path}", file=sys.stderr)
|
||||
sidecar_count += 1
|
||||
except Exception as e:
|
||||
log(f"[add_tag] Warning: Failed to write sidecar for {file_path}: {e}", file=sys.stderr)
|
||||
ctx.emit(res)
|
||||
if sidecar_count > 0:
|
||||
log(f"[add_tag] Wrote {sidecar_count} sidecar file(s) with embedded tags", file=sys.stderr)
|
||||
else:
|
||||
log(f"[add_tag] No tags to write - passed {len(results)} result(s) through unchanged", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
# Main loop: process results with tags to add
|
||||
total_new_tags = 0
|
||||
total_modified = 0
|
||||
for res in results:
|
||||
# Extract file info from result
|
||||
file_path = None
|
||||
existing_tags = []
|
||||
file_hash = ""
|
||||
storage_source = None
|
||||
|
||||
# Use canonical getters for fields from both dicts and PipeObject
|
||||
file_path = get_field(res, "path")
|
||||
existing_tags = get_field(res, "tags") or []
|
||||
if not existing_tags:
|
||||
existing_tags = (get_field(res, "extra", {}) or {}).get("tags") or []
|
||||
file_hash = get_field(res, "hash") or get_field(res, "file_hash") or get_field(res, "hash_hex") or ""
|
||||
storage_source = get_field(res, "store") or get_field(res, "storage") or get_field(res, "storage_source") or get_field(res, "origin")
|
||||
hydrus_hash = get_field(res, "hydrus_hash") or file_hash
|
||||
|
||||
# Infer storage source from result if not found
|
||||
if not storage_source:
|
||||
if file_path:
|
||||
storage_source = 'local'
|
||||
elif file_hash and file_hash != "unknown":
|
||||
storage_source = 'hydrus'
|
||||
|
||||
original_tags_lower = {str(t).lower() for t in existing_tags if isinstance(t, str)}
|
||||
original_title = self._extract_title_tag(list(existing_tags))
|
||||
|
||||
# Apply CLI overrides if provided
|
||||
if hash_override and not file_hash:
|
||||
file_hash = hash_override
|
||||
if store_override and not storage_source:
|
||||
storage_source = store_override
|
||||
|
||||
# Check if we have sufficient identifier (file_path OR file_hash)
|
||||
if not file_path and not file_hash:
|
||||
log(f"[add_tag] Warning: Result has neither path nor hash available, skipping", file=sys.stderr)
|
||||
ctx.emit(res)
|
||||
continue
|
||||
# Handle -duplicate logic (copy existing tags to new namespaces)
|
||||
if duplicate_arg:
|
||||
# Parse duplicate format: source:target1,target2 or source,target1,target2
|
||||
parts = duplicate_arg.split(':')
|
||||
source_ns = ""
|
||||
targets = []
|
||||
if len(parts) > 1:
|
||||
# Explicit format: source:target1,target2
|
||||
source_ns = parts[0]
|
||||
targets = parts[1].split(',')
|
||||
else:
|
||||
# Inferred format: source,target1,target2
|
||||
parts = duplicate_arg.split(',')
|
||||
if len(parts) > 1:
|
||||
source_ns = parts[0]
|
||||
targets = parts[1:]
|
||||
if source_ns and targets:
|
||||
# Find tags in source namespace
|
||||
source_tags = [t for t in existing_tags if t.startswith(source_ns + ':')]
|
||||
for t in source_tags:
|
||||
value = t.split(':', 1)[1]
|
||||
for target_ns in targets:
|
||||
new_tag = f"{target_ns}:{value}"
|
||||
if new_tag not in existing_tags and new_tag not in tags_to_add:
|
||||
tags_to_add.append(new_tag)
|
||||
|
||||
# Initialize tag mutation tracking local variables
|
||||
removed_tags = []
|
||||
new_tags_added = []
|
||||
final_tags = list(existing_tags) if existing_tags else []
|
||||
|
||||
# Determine where to add tags: Hydrus or Folder storage
|
||||
if storage_source and storage_source.lower() == 'hydrus':
|
||||
# Add tags to Hydrus using the API
|
||||
target_hash = file_hash
|
||||
if target_hash:
|
||||
try:
|
||||
hydrus_client = hydrus_wrapper.get_client(config)
|
||||
service_name = hydrus_wrapper.get_tag_service_name(config)
|
||||
|
||||
# For namespaced tags, remove old tags in same namespace
|
||||
removed_tags = []
|
||||
for new_tag in tags_to_add:
|
||||
if ':' in new_tag:
|
||||
namespace = new_tag.split(':', 1)[0]
|
||||
to_remove = [t for t in existing_tags if t.startswith(namespace + ':') and t.lower() != new_tag.lower()]
|
||||
removed_tags.extend(to_remove)
|
||||
|
||||
# Add new tags
|
||||
if tags_to_add:
|
||||
log(f"[add_tag] Adding {len(tags_to_add)} tag(s) to Hydrus file: {target_hash}", file=sys.stderr)
|
||||
hydrus_client.add_tags(target_hash, tags_to_add, service_name)
|
||||
|
||||
# Delete replaced namespace tags
|
||||
if removed_tags:
|
||||
unique_removed = sorted(set(removed_tags))
|
||||
hydrus_client.delete_tags(target_hash, unique_removed, service_name)
|
||||
|
||||
if tags_to_add or removed_tags:
|
||||
total_new_tags += len(tags_to_add)
|
||||
total_modified += 1
|
||||
log(f"[add_tag] ✓ Added {len(tags_to_add)} tag(s) to Hydrus", file=sys.stderr)
|
||||
# Refresh final tag list from the backend for accurate display
|
||||
try:
|
||||
from helper.store import FileStorage
|
||||
storage = FileStorage(config)
|
||||
if storage and storage_source in storage.list_backends():
|
||||
backend = storage[storage_source]
|
||||
refreshed_tags, _ = backend.get_tag(target_hash)
|
||||
if refreshed_tags is not None:
|
||||
final_tags = refreshed_tags
|
||||
new_tags_added = [t for t in refreshed_tags if t.lower() not in original_tags_lower]
|
||||
# Update result tags for downstream cmdlets/UI
|
||||
if isinstance(res, models.PipeObject):
|
||||
res.tags = refreshed_tags
|
||||
if isinstance(res.extra, dict):
|
||||
res.extra['tags'] = refreshed_tags
|
||||
elif isinstance(res, dict):
|
||||
res['tags'] = refreshed_tags
|
||||
except Exception:
|
||||
# Ignore failures - this is best-effort for refreshing tag state
|
||||
pass
|
||||
except Exception as e:
|
||||
log(f"[add_tag] Warning: Failed to add tags to Hydrus: {e}", file=sys.stderr)
|
||||
else:
|
||||
log(f"[add_tag] Warning: No hash available for Hydrus file, skipping", file=sys.stderr)
|
||||
elif storage_source:
|
||||
# For any Folder-based storage (local, test, default, etc.), delegate to backend
|
||||
# If storage_source is not a registered backend, fallback to writing a sidecar
|
||||
from helper.store import FileStorage
|
||||
storage = FileStorage(config)
|
||||
try:
|
||||
if storage and storage_source in storage.list_backends():
|
||||
backend = storage[storage_source]
|
||||
if file_hash and backend.add_tag(file_hash, tags_to_add):
|
||||
# Refresh tags from backend to get merged result
|
||||
refreshed_tags, _ = backend.get_tag(file_hash)
|
||||
if refreshed_tags:
|
||||
# Update result tags
|
||||
if isinstance(res, models.PipeObject):
|
||||
res.tags = refreshed_tags
|
||||
# Also keep as extra for compatibility
|
||||
if isinstance(res.extra, dict):
|
||||
res.extra['tags'] = refreshed_tags
|
||||
elif isinstance(res, dict):
|
||||
res['tags'] = refreshed_tags
|
||||
|
||||
# Update title if changed
|
||||
title_value = self._extract_title_tag(refreshed_tags)
|
||||
self._apply_title_to_result(res, title_value)
|
||||
|
||||
# Compute stats
|
||||
new_tags_added = [t for t in refreshed_tags if t.lower() not in original_tags_lower]
|
||||
total_new_tags += len(new_tags_added)
|
||||
if new_tags_added:
|
||||
total_modified += 1
|
||||
|
||||
log(f"[add_tag] Added {len(new_tags_added)} new tag(s); {len(refreshed_tags)} total tag(s) stored in {storage_source}", file=sys.stderr)
|
||||
final_tags = refreshed_tags
|
||||
else:
|
||||
log(f"[add_tag] Warning: Failed to add tags to {storage_source}", file=sys.stderr)
|
||||
else:
|
||||
# Not a registered backend - fallback to sidecar if we have a path
|
||||
if file_path:
|
||||
try:
|
||||
sidecar_path = write_sidecar(Path(file_path), tags_to_add, [], file_hash)
|
||||
log(f"[add_tag] Wrote {len(tags_to_add)} tag(s) to sidecar: {sidecar_path}", file=sys.stderr)
|
||||
total_new_tags += len(tags_to_add)
|
||||
total_modified += 1
|
||||
# Update res tags
|
||||
if isinstance(res, models.PipeObject):
|
||||
res.tags = (res.tags or []) + tags_to_add
|
||||
if isinstance(res.extra, dict):
|
||||
res.extra['tags'] = res.tags
|
||||
elif isinstance(res, dict):
|
||||
res['tags'] = list(set((res.get('tags') or []) + tags_to_add))
|
||||
except Exception as exc:
|
||||
log(f"[add_tag] Warning: Failed to write sidecar for {file_path}: {exc}", file=sys.stderr)
|
||||
else:
|
||||
log(f"[add_tag] Warning: Storage backend '{storage_source}' not found in config", file=sys.stderr)
|
||||
except KeyError:
|
||||
# storage[storage_source] raised KeyError - treat as absent backend
|
||||
if file_path:
|
||||
try:
|
||||
sidecar_path = write_sidecar(Path(file_path), tags_to_add, [], file_hash)
|
||||
log(f"[add_tag] Wrote {len(tags_to_add)} tag(s) to sidecar: {sidecar_path}", file=sys.stderr)
|
||||
total_new_tags += len(tags_to_add)
|
||||
total_modified += 1
|
||||
# Update res tags for downstream
|
||||
if isinstance(res, models.PipeObject):
|
||||
res.tags = (res.tags or []) + tags_to_add
|
||||
if isinstance(res.extra, dict):
|
||||
res.extra['tags'] = res.tags
|
||||
elif isinstance(res, dict):
|
||||
res['tags'] = list(set((res.get('tags') or []) + tags_to_add))
|
||||
except Exception as exc:
|
||||
log(f"[add_tag] Warning: Failed to write sidecar for {file_path}: {exc}", file=sys.stderr)
|
||||
else:
|
||||
log(f"[add_tag] Warning: Storage backend '{storage_source}' not found in config", file=sys.stderr)
|
||||
else:
|
||||
# For other storage types or unknown sources, avoid writing sidecars to reduce clutter
|
||||
# (local/hydrus are handled above).
|
||||
ctx.emit(res)
|
||||
continue
|
||||
# If title changed, refresh the cached result table so the display reflects the new name
|
||||
final_title = self._extract_title_tag(final_tags)
|
||||
if final_title and (not original_title or final_title.lower() != original_title.lower()):
|
||||
self._refresh_result_table_title(final_title, hydrus_hash or file_hash, file_hash, file_path)
|
||||
# If tags changed, refresh tag view via get-tag (prefer current subject; fall back to hash refresh)
|
||||
if new_tags_added or removed_tags:
|
||||
self._refresh_tags_view(res, hydrus_hash, file_hash, file_path, config)
|
||||
# Emit the modified result
|
||||
ctx.emit(res)
|
||||
log(f"[add_tag] Added {total_new_tags} new tag(s) across {len(results)} item(s); modified {total_modified} item(s)", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
CMDLET = Add_Tag()
|
||||
@@ -1,20 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Sequence, Optional
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from helper.logger import log
|
||||
|
||||
from . import register
|
||||
import models
|
||||
import pipeline as ctx
|
||||
from ._shared import normalize_result_input, filter_results_by_temp
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from helper.local_library import read_sidecar, write_sidecar, find_sidecar, has_sidecar, LocalLibraryDB
|
||||
from helper.folder_store import read_sidecar, write_sidecar, find_sidecar, has_sidecar, FolderDB
|
||||
from metadata import rename
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash, parse_tag_arguments, expand_tag_groups, parse_cmdlet_args, collapse_namespace_tags
|
||||
from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, parse_tag_arguments, expand_tag_groups, parse_cmdlet_args, collapse_namespace_tags, should_show_help, get_field
|
||||
from config import get_local_storage_path
|
||||
|
||||
|
||||
@@ -68,29 +66,16 @@ def _matches_target(item: Any, hydrus_hash: Optional[str], file_hash: Optional[s
|
||||
def norm(val: Any) -> Optional[str]:
|
||||
return str(val).lower() if val is not None else None
|
||||
|
||||
# Define field names to check for hashes and paths
|
||||
hash_fields = ["hydrus_hash", "hash", "hash_hex", "file_hash"]
|
||||
path_fields = ["path", "file_path", "target"]
|
||||
|
||||
if isinstance(item, dict):
|
||||
hashes = [
|
||||
norm(item.get("hydrus_hash")),
|
||||
norm(item.get("hash")),
|
||||
norm(item.get("hash_hex")),
|
||||
norm(item.get("file_hash")),
|
||||
]
|
||||
paths = [
|
||||
norm(item.get("path")),
|
||||
norm(item.get("file_path")),
|
||||
norm(item.get("target")),
|
||||
]
|
||||
hashes = [norm(item.get(field)) for field in hash_fields]
|
||||
paths = [norm(item.get(field)) for field in path_fields]
|
||||
else:
|
||||
hashes = [
|
||||
norm(getattr(item, "hydrus_hash", None)),
|
||||
norm(getattr(item, "hash_hex", None)),
|
||||
norm(getattr(item, "file_hash", None)),
|
||||
]
|
||||
paths = [
|
||||
norm(getattr(item, "path", None)),
|
||||
norm(getattr(item, "file_path", None)),
|
||||
norm(getattr(item, "target", None)),
|
||||
]
|
||||
hashes = [norm(get_field(item, field)) for field in hash_fields]
|
||||
paths = [norm(get_field(item, field)) for field in path_fields]
|
||||
|
||||
if hydrus_hash_l and hydrus_hash_l in hashes:
|
||||
return True
|
||||
@@ -147,20 +132,18 @@ def _refresh_result_table_title(new_title: str, hydrus_hash: Optional[str], file
|
||||
except Exception:
|
||||
pass
|
||||
updated_items.append(item)
|
||||
|
||||
if not match_found:
|
||||
return
|
||||
|
||||
from result_table import ResultTable # Local import to avoid circular dependency
|
||||
|
||||
new_table = ResultTable(getattr(last_table, "title", ""), title_width=getattr(last_table, "title_width", 80), max_columns=getattr(last_table, "max_columns", None))
|
||||
if getattr(last_table, "source_command", None):
|
||||
new_table.set_source_command(last_table.source_command, getattr(last_table, "source_args", []))
|
||||
new_table = last_table.copy_with_title(getattr(last_table, "title", ""))
|
||||
|
||||
for item in updated_items:
|
||||
new_table.add_result(item)
|
||||
|
||||
ctx.set_last_result_table_preserve_history(new_table, updated_items)
|
||||
# Keep the underlying history intact; update only the overlay so @.. can
|
||||
# clear the overlay then continue back to prior tables (e.g., the search list).
|
||||
ctx.set_last_result_table_overlay(new_table, updated_items)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -194,347 +177,409 @@ def _refresh_tags_view(res: Any, hydrus_hash: Optional[str], file_hash: Optional
|
||||
|
||||
|
||||
|
||||
class Add_Tag(Cmdlet):
|
||||
"""Class-based add-tags cmdlet with Cmdlet metadata inheritance."""
|
||||
|
||||
@register(["add-tag", "add-tags"])
|
||||
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Add tags to a file with smart filtering for pipeline results."""
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
name="add-tags",
|
||||
summary="Add tags to a Hydrus file or write them to a local .tags sidecar.",
|
||||
usage="add-tags [-hash <sha256>] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]",
|
||||
arg=[
|
||||
SharedArgs.HASH,
|
||||
CmdletArg("-duplicate", type="string", description="Copy existing tag values to new namespaces. Formats: title:album,artist (explicit) or title,album,artist (inferred)"),
|
||||
CmdletArg("-list", type="string", description="Load predefined tag lists from adjective.json. Comma-separated list names (e.g., -list philosophy,occult)."),
|
||||
CmdletArg("--all", type="flag", description="Include temporary files in tagging (by default, only tags non-temporary files)."),
|
||||
CmdletArg("tags", type="string", required=False, description="One or more tags to add. Comma- or space-separated. Can also use {list_name} syntax. If omitted, uses tags from pipeline payload.", variadic=True),
|
||||
],
|
||||
detail=[
|
||||
"- By default, only tags non-temporary files (from pipelines). Use --all to tag everything.",
|
||||
"- Without -hash and when the selection is a local file, tags are written to <file>.tags.",
|
||||
"- With a Hydrus hash, tags are sent to the 'my tags' service.",
|
||||
"- Multiple tags can be comma-separated or space-separated.",
|
||||
"- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult",
|
||||
"- Tags can also reference lists with curly braces: add-tag {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).",
|
||||
"- You can also pass the target hash as a tag token: hash:<sha256>. This overrides -hash and is removed from the tag list.",
|
||||
],
|
||||
exec=self.run,
|
||||
)
|
||||
self.register()
|
||||
|
||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Add tags to a file with smart filtering for pipeline results."""
|
||||
if should_show_help(args):
|
||||
log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}")
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Parse arguments
|
||||
parsed = parse_cmdlet_args(args, CMDLET)
|
||||
|
||||
# Check for --all flag
|
||||
include_temp = parsed.get("all", False)
|
||||
|
||||
# Normalize input to list
|
||||
results = normalize_result_input(result)
|
||||
|
||||
# Filter by temp status (unless --all is set)
|
||||
if not include_temp:
|
||||
results = filter_results_by_temp(results, include_temp=False)
|
||||
|
||||
if not results:
|
||||
log("No valid files to tag (all results were temporary; use --all to include temporary files)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Get tags from arguments (or fallback to pipeline payload)
|
||||
raw_tags = parsed.get("tags", [])
|
||||
if isinstance(raw_tags, str):
|
||||
raw_tags = [raw_tags]
|
||||
|
||||
# Fallback: if no tags provided explicitly, try to pull from first result payload
|
||||
if not raw_tags and results:
|
||||
first = results[0]
|
||||
payload_tags = None
|
||||
if isinstance(first, models.PipeObject):
|
||||
payload_tags = first.extra.get("tags") if isinstance(first.extra, dict) else None
|
||||
elif isinstance(first, dict):
|
||||
payload_tags = first.get("tags")
|
||||
if not payload_tags:
|
||||
payload_tags = first.get("extra", {}).get("tags") if isinstance(first.get("extra"), dict) else None
|
||||
# If metadata payload stored tags under nested list, accept directly
|
||||
if payload_tags is None:
|
||||
payload_tags = getattr(first, "tags", None)
|
||||
if payload_tags:
|
||||
if isinstance(payload_tags, str):
|
||||
raw_tags = [payload_tags]
|
||||
elif isinstance(payload_tags, list):
|
||||
raw_tags = payload_tags
|
||||
|
||||
# Handle -list argument (convert to {list} syntax)
|
||||
list_arg = parsed.get("list")
|
||||
if list_arg:
|
||||
for l in list_arg.split(','):
|
||||
l = l.strip()
|
||||
if l:
|
||||
raw_tags.append(f"{{{l}}}")
|
||||
|
||||
# Parse and expand tags
|
||||
tags_to_add = parse_tag_arguments(raw_tags)
|
||||
tags_to_add = expand_tag_groups(tags_to_add)
|
||||
|
||||
if not tags_to_add:
|
||||
log("No tags provided to add", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Get other flags
|
||||
hash_override = normalize_hash(parsed.get("hash"))
|
||||
duplicate_arg = parsed.get("duplicate")
|
||||
|
||||
# If no tags provided (and no list), write sidecar files with embedded tags
|
||||
# Note: Since 'tags' is required=True in CMDLET, this block might be unreachable via CLI
|
||||
# unless called programmatically or if required check is bypassed.
|
||||
if not tags_to_add and not duplicate_arg:
|
||||
# Write sidecar files with the tags that are already in the result dicts
|
||||
|
||||
# Parse arguments
|
||||
parsed = parse_cmdlet_args(args, self)
|
||||
|
||||
# Check for --all flag
|
||||
include_temp = parsed.get("all", False)
|
||||
|
||||
# Normalize input to list
|
||||
results = normalize_result_input(result)
|
||||
|
||||
# Filter by temp status (unless --all is set)
|
||||
if not include_temp:
|
||||
results = filter_results_by_temp(results, include_temp=False)
|
||||
|
||||
if not results:
|
||||
log("No valid files to tag (all results were temporary; use --all to include temporary files)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Get tags from arguments (or fallback to pipeline payload)
|
||||
raw_tags = parsed.get("tags", [])
|
||||
if isinstance(raw_tags, str):
|
||||
raw_tags = [raw_tags]
|
||||
|
||||
# Fallback: if no tags provided explicitly, try to pull from first result payload
|
||||
if not raw_tags and results:
|
||||
first = results[0]
|
||||
payload_tags = None
|
||||
|
||||
# Try multiple tag lookup strategies in order
|
||||
tag_lookups = [
|
||||
lambda x: x.extra.get("tags") if isinstance(x, models.PipeObject) and isinstance(x.extra, dict) else None,
|
||||
lambda x: x.get("tags") if isinstance(x, dict) else None,
|
||||
lambda x: x.get("extra", {}).get("tags") if isinstance(x, dict) and isinstance(x.get("extra"), dict) else None,
|
||||
lambda x: getattr(x, "tags", None),
|
||||
]
|
||||
|
||||
for lookup in tag_lookups:
|
||||
try:
|
||||
payload_tags = lookup(first)
|
||||
if payload_tags:
|
||||
break
|
||||
except (AttributeError, TypeError, KeyError):
|
||||
continue
|
||||
|
||||
if payload_tags:
|
||||
if isinstance(payload_tags, str):
|
||||
raw_tags = [payload_tags]
|
||||
elif isinstance(payload_tags, list):
|
||||
raw_tags = payload_tags
|
||||
|
||||
# Handle -list argument (convert to {list} syntax)
|
||||
list_arg = parsed.get("list")
|
||||
if list_arg:
|
||||
for l in list_arg.split(','):
|
||||
l = l.strip()
|
||||
if l:
|
||||
raw_tags.append(f"{{{l}}}")
|
||||
|
||||
# Parse and expand tags
|
||||
tags_to_add = parse_tag_arguments(raw_tags)
|
||||
tags_to_add = expand_tag_groups(tags_to_add)
|
||||
|
||||
# Allow hash override via namespaced token (e.g., "hash:abcdef...")
|
||||
extracted_hash = None
|
||||
filtered_tags: List[str] = []
|
||||
for tag in tags_to_add:
|
||||
if isinstance(tag, str) and tag.lower().startswith("hash:"):
|
||||
_, _, hash_val = tag.partition(":")
|
||||
if hash_val:
|
||||
extracted_hash = normalize_hash(hash_val.strip())
|
||||
continue
|
||||
filtered_tags.append(tag)
|
||||
tags_to_add = filtered_tags
|
||||
|
||||
if not tags_to_add:
|
||||
log("No tags provided to add", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Get other flags (hash override can come from -hash or hash: token)
|
||||
hash_override = normalize_hash(parsed.get("hash")) or extracted_hash
|
||||
duplicate_arg = parsed.get("duplicate")
|
||||
|
||||
# If no tags provided (and no list), write sidecar files with embedded tags
|
||||
# Note: Since 'tags' is required=False in the cmdlet arg, this block can be reached via CLI
|
||||
# when no tag arguments are provided.
|
||||
if not tags_to_add and not duplicate_arg:
|
||||
# Write sidecar files with the tags that are already in the result dicts
|
||||
sidecar_count = 0
|
||||
for res in results:
|
||||
# Handle both dict and PipeObject formats
|
||||
file_path = None
|
||||
tags = []
|
||||
file_hash = ""
|
||||
|
||||
if isinstance(res, models.PipeObject):
|
||||
file_path = res.file_path
|
||||
tags = res.extra.get('tags', [])
|
||||
file_hash = res.hash or ""
|
||||
elif isinstance(res, dict):
|
||||
file_path = res.get('file_path')
|
||||
# Try multiple tag locations in order
|
||||
tag_sources = [lambda: res.get('tags', []), lambda: res.get('extra', {}).get('tags', [])]
|
||||
for source in tag_sources:
|
||||
tags = source()
|
||||
if tags:
|
||||
break
|
||||
file_hash = res.get('hash', "")
|
||||
|
||||
if not file_path:
|
||||
log(f"[add_tags] Warning: Result has no file_path, skipping", file=sys.stderr)
|
||||
ctx.emit(res)
|
||||
continue
|
||||
|
||||
if tags:
|
||||
# Write sidecar file for this file with its tags
|
||||
try:
|
||||
sidecar_path = write_sidecar(Path(file_path), tags, [], file_hash)
|
||||
log(f"[add_tags] Wrote {len(tags)} tag(s) to sidecar: {sidecar_path}", file=sys.stderr)
|
||||
sidecar_count += 1
|
||||
except Exception as e:
|
||||
log(f"[add_tags] Warning: Failed to write sidecar for {file_path}: {e}", file=sys.stderr)
|
||||
|
||||
ctx.emit(res)
|
||||
|
||||
if sidecar_count > 0:
|
||||
log(f"[add_tags] Wrote {sidecar_count} sidecar file(s) with embedded tags", file=sys.stderr)
|
||||
else:
|
||||
log(f"[add_tags] No tags to write - passed {len(results)} result(s) through unchanged", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
# Tags ARE provided - append them to each result and write sidecar files or add to Hydrus
|
||||
sidecar_count = 0
|
||||
total_new_tags = 0
|
||||
total_modified = 0
|
||||
for res in results:
|
||||
# Handle both dict and PipeObject formats
|
||||
file_path = None
|
||||
tags = []
|
||||
existing_tags = []
|
||||
file_hash = ""
|
||||
|
||||
storage_source = None
|
||||
hydrus_hash = None
|
||||
|
||||
# Define field name aliases to check
|
||||
path_field_names = ['file_path', 'path']
|
||||
source_field_names = ['storage_source', 'source', 'origin']
|
||||
hash_field_names = ['hydrus_hash', 'hash', 'hash_hex']
|
||||
|
||||
if isinstance(res, models.PipeObject):
|
||||
file_path = res.file_path
|
||||
tags = res.extra.get('tags', [])
|
||||
existing_tags = res.extra.get('tags', [])
|
||||
file_hash = res.file_hash or ""
|
||||
for field in source_field_names:
|
||||
storage_source = res.extra.get(field)
|
||||
if storage_source:
|
||||
break
|
||||
hydrus_hash = res.extra.get('hydrus_hash')
|
||||
elif isinstance(res, dict):
|
||||
file_path = res.get('file_path')
|
||||
tags = res.get('tags', []) # Check both tags and extra['tags']
|
||||
if not tags and 'extra' in res:
|
||||
tags = res['extra'].get('tags', [])
|
||||
# Try path field names in order
|
||||
for field in path_field_names:
|
||||
file_path = res.get(field)
|
||||
if file_path:
|
||||
break
|
||||
|
||||
# Try tag locations in order
|
||||
tag_sources = [lambda: res.get('tags', []), lambda: res.get('extra', {}).get('tags', [])]
|
||||
for source in tag_sources:
|
||||
existing_tags = source()
|
||||
if existing_tags:
|
||||
break
|
||||
|
||||
file_hash = res.get('file_hash', "")
|
||||
|
||||
if not file_path:
|
||||
log(f"[add_tags] Warning: Result has no file_path, skipping", file=sys.stderr)
|
||||
|
||||
# Try source field names in order (top-level then extra)
|
||||
for field in source_field_names:
|
||||
storage_source = res.get(field)
|
||||
if storage_source:
|
||||
break
|
||||
if not storage_source and 'extra' in res:
|
||||
for field in source_field_names:
|
||||
storage_source = res.get('extra', {}).get(field)
|
||||
if storage_source:
|
||||
break
|
||||
|
||||
# Try hash field names in order (top-level then extra)
|
||||
for field in hash_field_names:
|
||||
hydrus_hash = res.get(field)
|
||||
if hydrus_hash:
|
||||
break
|
||||
if not hydrus_hash and 'extra' in res:
|
||||
for field in hash_field_names:
|
||||
hydrus_hash = res.get('extra', {}).get(field)
|
||||
if hydrus_hash:
|
||||
break
|
||||
|
||||
if not hydrus_hash and file_hash:
|
||||
hydrus_hash = file_hash
|
||||
if not storage_source and hydrus_hash and not file_path:
|
||||
storage_source = 'hydrus'
|
||||
# If we have a file path but no storage source, assume local to avoid sidecar spam
|
||||
if not storage_source and file_path:
|
||||
storage_source = 'local'
|
||||
else:
|
||||
ctx.emit(res)
|
||||
continue
|
||||
|
||||
if tags:
|
||||
# Write sidecar file for this file with its tags
|
||||
try:
|
||||
sidecar_path = write_sidecar(Path(file_path), tags, [], file_hash)
|
||||
log(f"[add_tags] Wrote {len(tags)} tag(s) to sidecar: {sidecar_path}", file=sys.stderr)
|
||||
sidecar_count += 1
|
||||
except Exception as e:
|
||||
log(f"[add_tags] Warning: Failed to write sidecar for {file_path}: {e}", file=sys.stderr)
|
||||
|
||||
ctx.emit(res)
|
||||
|
||||
if sidecar_count > 0:
|
||||
log(f"[add_tags] Wrote {sidecar_count} sidecar file(s) with embedded tags", file=sys.stderr)
|
||||
else:
|
||||
log(f"[add_tags] No tags to write - passed {len(results)} result(s) through unchanged", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
# Tags ARE provided - append them to each result and write sidecar files or add to Hydrus
|
||||
sidecar_count = 0
|
||||
total_new_tags = 0
|
||||
total_modified = 0
|
||||
for res in results:
|
||||
# Handle both dict and PipeObject formats
|
||||
file_path = None
|
||||
existing_tags = []
|
||||
file_hash = ""
|
||||
storage_source = None
|
||||
hydrus_hash = None
|
||||
|
||||
if isinstance(res, models.PipeObject):
|
||||
file_path = res.file_path
|
||||
existing_tags = res.extra.get('tags', [])
|
||||
file_hash = res.file_hash or ""
|
||||
storage_source = res.extra.get('storage_source') or res.extra.get('source')
|
||||
hydrus_hash = res.extra.get('hydrus_hash')
|
||||
elif isinstance(res, dict):
|
||||
file_path = res.get('file_path') or res.get('path')
|
||||
existing_tags = res.get('tags', [])
|
||||
if not existing_tags and 'extra' in res:
|
||||
existing_tags = res['extra'].get('tags', [])
|
||||
file_hash = res.get('file_hash', "")
|
||||
storage_source = res.get('storage_source') or res.get('source') or res.get('origin')
|
||||
if not storage_source and 'extra' in res:
|
||||
storage_source = res['extra'].get('storage_source') or res['extra'].get('source')
|
||||
# For Hydrus results from search-file, look for hash, hash_hex, or target (all contain the hash)
|
||||
hydrus_hash = res.get('hydrus_hash') or res.get('hash') or res.get('hash_hex')
|
||||
if not hydrus_hash and 'extra' in res:
|
||||
hydrus_hash = res['extra'].get('hydrus_hash') or res['extra'].get('hash') or res['extra'].get('hash_hex')
|
||||
if not hydrus_hash and file_hash:
|
||||
hydrus_hash = file_hash
|
||||
if not storage_source and hydrus_hash and not file_path:
|
||||
storage_source = 'hydrus'
|
||||
# If we have a file path but no storage source, assume local to avoid sidecar spam
|
||||
if not storage_source and file_path:
|
||||
storage_source = 'local'
|
||||
else:
|
||||
ctx.emit(res)
|
||||
continue
|
||||
|
||||
original_tags_lower = {str(t).lower() for t in existing_tags if isinstance(t, str)}
|
||||
original_tags_snapshot = list(existing_tags)
|
||||
original_title = _extract_title_tag(original_tags_snapshot)
|
||||
removed_tags: List[str] = []
|
||||
|
||||
# Apply hash override if provided
|
||||
if hash_override:
|
||||
hydrus_hash = hash_override
|
||||
# If we have a hash override, we treat it as a Hydrus target
|
||||
storage_source = "hydrus"
|
||||
|
||||
if not file_path and not hydrus_hash:
|
||||
log(f"[add_tags] Warning: Result has neither file_path nor hash available, skipping", file=sys.stderr)
|
||||
ctx.emit(res)
|
||||
continue
|
||||
|
||||
# Handle -duplicate logic (copy existing tags to new namespaces)
|
||||
if duplicate_arg:
|
||||
# Parse duplicate format: source:target1,target2 or source,target1,target2
|
||||
parts = duplicate_arg.split(':')
|
||||
source_ns = ""
|
||||
targets = []
|
||||
|
||||
if len(parts) > 1:
|
||||
# Explicit format: source:target1,target2
|
||||
source_ns = parts[0]
|
||||
targets = parts[1].split(',')
|
||||
else:
|
||||
# Inferred format: source,target1,target2
|
||||
parts = duplicate_arg.split(',')
|
||||
original_tags_lower = {str(t).lower() for t in existing_tags if isinstance(t, str)}
|
||||
original_tags_snapshot = list(existing_tags)
|
||||
original_title = _extract_title_tag(original_tags_snapshot)
|
||||
removed_tags: List[str] = []
|
||||
|
||||
# Apply hash override if provided
|
||||
if hash_override:
|
||||
hydrus_hash = hash_override
|
||||
# If we have a hash override, we treat it as a Hydrus target
|
||||
storage_source = "hydrus"
|
||||
|
||||
if not file_path and not hydrus_hash:
|
||||
log(f"[add_tags] Warning: Result has neither file_path nor hash available, skipping", file=sys.stderr)
|
||||
ctx.emit(res)
|
||||
continue
|
||||
|
||||
# Handle -duplicate logic (copy existing tags to new namespaces)
|
||||
if duplicate_arg:
|
||||
# Parse duplicate format: source:target1,target2 or source,target1,target2
|
||||
parts = duplicate_arg.split(':')
|
||||
source_ns = ""
|
||||
targets = []
|
||||
|
||||
if len(parts) > 1:
|
||||
# Explicit format: source:target1,target2
|
||||
source_ns = parts[0]
|
||||
targets = parts[1:]
|
||||
|
||||
if source_ns and targets:
|
||||
# Find tags in source namespace
|
||||
source_tags = [t for t in existing_tags if t.startswith(source_ns + ':')]
|
||||
for t in source_tags:
|
||||
value = t.split(':', 1)[1]
|
||||
for target_ns in targets:
|
||||
new_tag = f"{target_ns}:{value}"
|
||||
if new_tag not in existing_tags and new_tag not in tags_to_add:
|
||||
tags_to_add.append(new_tag)
|
||||
|
||||
# Merge new tags with existing tags, handling namespace overwrites
|
||||
# When adding a tag like "namespace:value", remove any existing "namespace:*" tags
|
||||
for new_tag in tags_to_add:
|
||||
# Check if this is a namespaced tag (format: "namespace:value")
|
||||
if ':' in new_tag:
|
||||
namespace = new_tag.split(':', 1)[0]
|
||||
# Track removals for Hydrus: delete old tags in same namespace (except identical)
|
||||
to_remove = [t for t in existing_tags if t.startswith(namespace + ':') and t.lower() != new_tag.lower()]
|
||||
removed_tags.extend(to_remove)
|
||||
# Remove any existing tags with the same namespace
|
||||
existing_tags = [t for t in existing_tags if not (t.startswith(namespace + ':'))]
|
||||
|
||||
# Add the new tag if not already present
|
||||
if new_tag not in existing_tags:
|
||||
existing_tags.append(new_tag)
|
||||
|
||||
# Ensure only one tag per namespace (e.g., single title:) with latest preferred
|
||||
existing_tags = collapse_namespace_tags(existing_tags, "title", prefer="last")
|
||||
|
||||
# Compute new tags relative to original
|
||||
new_tags_added = [t for t in existing_tags if isinstance(t, str) and t.lower() not in original_tags_lower]
|
||||
total_new_tags += len(new_tags_added)
|
||||
|
||||
# Update the result's tags
|
||||
if isinstance(res, models.PipeObject):
|
||||
res.extra['tags'] = existing_tags
|
||||
elif isinstance(res, dict):
|
||||
res['tags'] = existing_tags
|
||||
|
||||
# If a title: tag was added, update the in-memory title and columns so downstream display reflects it immediately
|
||||
title_value = _extract_title_tag(existing_tags)
|
||||
_apply_title_to_result(res, title_value)
|
||||
|
||||
final_tags = existing_tags
|
||||
|
||||
# Determine where to add tags: Hydrus, local DB, or sidecar
|
||||
if storage_source and storage_source.lower() == 'hydrus':
|
||||
# Add tags to Hydrus using the API
|
||||
target_hash = hydrus_hash or file_hash
|
||||
if target_hash:
|
||||
try:
|
||||
tags_to_send = [t for t in existing_tags if isinstance(t, str) and t.lower() not in original_tags_lower]
|
||||
hydrus_client = hydrus_wrapper.get_client(config)
|
||||
service_name = hydrus_wrapper.get_tag_service_name(config)
|
||||
if tags_to_send:
|
||||
log(f"[add_tags] Adding {len(tags_to_send)} new tag(s) to Hydrus file: {target_hash}", file=sys.stderr)
|
||||
hydrus_client.add_tags(target_hash, tags_to_send, service_name)
|
||||
else:
|
||||
log(f"[add_tags] No new tags to add for Hydrus file: {target_hash}", file=sys.stderr)
|
||||
# Delete old namespace tags we replaced (e.g., previous title:)
|
||||
if removed_tags:
|
||||
unique_removed = sorted(set(removed_tags))
|
||||
hydrus_client.delete_tags(target_hash, unique_removed, service_name)
|
||||
if tags_to_send:
|
||||
log(f"[add_tags] ✓ Tags added to Hydrus", file=sys.stderr)
|
||||
elif removed_tags:
|
||||
log(f"[add_tags] ✓ Removed {len(unique_removed)} tag(s) from Hydrus", file=sys.stderr)
|
||||
sidecar_count += 1
|
||||
if tags_to_send or removed_tags:
|
||||
total_modified += 1
|
||||
except Exception as e:
|
||||
log(f"[add_tags] Warning: Failed to add tags to Hydrus: {e}", file=sys.stderr)
|
||||
else:
|
||||
log(f"[add_tags] Warning: No hash available for Hydrus file, skipping", file=sys.stderr)
|
||||
elif storage_source and storage_source.lower() == 'local':
|
||||
# For local storage, save directly to DB (no sidecar needed)
|
||||
if file_path:
|
||||
library_root = get_local_storage_path(config)
|
||||
if library_root:
|
||||
try:
|
||||
path_obj = Path(file_path)
|
||||
with LocalLibraryDB(library_root) as db:
|
||||
db.save_tags(path_obj, existing_tags)
|
||||
# Reload tags to reflect DB state (preserves auto-title logic)
|
||||
refreshed_tags = db.get_tags(path_obj) or existing_tags
|
||||
# Recompute title from refreshed tags for accurate display
|
||||
refreshed_title = _extract_title_tag(refreshed_tags)
|
||||
if refreshed_title:
|
||||
_apply_title_to_result(res, refreshed_title)
|
||||
res_tags = refreshed_tags or existing_tags
|
||||
if isinstance(res, models.PipeObject):
|
||||
res.extra['tags'] = res_tags
|
||||
elif isinstance(res, dict):
|
||||
res['tags'] = res_tags
|
||||
log(f"[add_tags] Added {len(new_tags_added)} new tag(s); {len(res_tags)} total tag(s) stored locally", file=sys.stderr)
|
||||
sidecar_count += 1
|
||||
if new_tags_added or removed_tags:
|
||||
total_modified += 1
|
||||
final_tags = res_tags
|
||||
except Exception as e:
|
||||
log(f"[add_tags] Warning: Failed to save tags to local DB: {e}", file=sys.stderr)
|
||||
targets = parts[1].split(',')
|
||||
else:
|
||||
log(f"[add_tags] Warning: No library root configured for local storage, skipping", file=sys.stderr)
|
||||
# Inferred format: source,target1,target2
|
||||
parts = duplicate_arg.split(',')
|
||||
if len(parts) > 1:
|
||||
source_ns = parts[0]
|
||||
targets = parts[1:]
|
||||
|
||||
if source_ns and targets:
|
||||
# Find tags in source namespace
|
||||
source_tags = [t for t in existing_tags if t.startswith(source_ns + ':')]
|
||||
for t in source_tags:
|
||||
value = t.split(':', 1)[1]
|
||||
for target_ns in targets:
|
||||
new_tag = f"{target_ns}:{value}"
|
||||
if new_tag not in existing_tags and new_tag not in tags_to_add:
|
||||
tags_to_add.append(new_tag)
|
||||
|
||||
# Merge new tags with existing tags, handling namespace overwrites
|
||||
# When adding a tag like "namespace:value", remove any existing "namespace:*" tags
|
||||
for new_tag in tags_to_add:
|
||||
# Check if this is a namespaced tag (format: "namespace:value")
|
||||
if ':' in new_tag:
|
||||
namespace = new_tag.split(':', 1)[0]
|
||||
# Track removals for Hydrus: delete old tags in same namespace (except identical)
|
||||
to_remove = [t for t in existing_tags if t.startswith(namespace + ':') and t.lower() != new_tag.lower()]
|
||||
removed_tags.extend(to_remove)
|
||||
# Remove any existing tags with the same namespace
|
||||
existing_tags = [t for t in existing_tags if not (t.startswith(namespace + ':'))]
|
||||
|
||||
# Add the new tag if not already present
|
||||
if new_tag not in existing_tags:
|
||||
existing_tags.append(new_tag)
|
||||
|
||||
# Ensure only one tag per namespace (e.g., single title:) with latest preferred
|
||||
existing_tags = collapse_namespace_tags(existing_tags, "title", prefer="last")
|
||||
|
||||
# Compute new tags relative to original
|
||||
new_tags_added = [t for t in existing_tags if isinstance(t, str) and t.lower() not in original_tags_lower]
|
||||
total_new_tags += len(new_tags_added)
|
||||
|
||||
# Update the result's tags
|
||||
if isinstance(res, models.PipeObject):
|
||||
res.extra['tags'] = existing_tags
|
||||
elif isinstance(res, dict):
|
||||
res['tags'] = existing_tags
|
||||
|
||||
# If a title: tag was added, update the in-memory title and columns so downstream display reflects it immediately
|
||||
title_value = _extract_title_tag(existing_tags)
|
||||
_apply_title_to_result(res, title_value)
|
||||
|
||||
final_tags = existing_tags
|
||||
|
||||
# Determine where to add tags: Hydrus, local DB, or sidecar
|
||||
if storage_source and storage_source.lower() == 'hydrus':
|
||||
# Add tags to Hydrus using the API
|
||||
target_hash = hydrus_hash or file_hash
|
||||
if target_hash:
|
||||
try:
|
||||
tags_to_send = [t for t in existing_tags if isinstance(t, str) and t.lower() not in original_tags_lower]
|
||||
hydrus_client = hydrus_wrapper.get_client(config)
|
||||
service_name = hydrus_wrapper.get_tag_service_name(config)
|
||||
if tags_to_send:
|
||||
log(f"[add_tags] Adding {len(tags_to_send)} new tag(s) to Hydrus file: {target_hash}", file=sys.stderr)
|
||||
hydrus_client.add_tags(target_hash, tags_to_send, service_name)
|
||||
else:
|
||||
log(f"[add_tags] No new tags to add for Hydrus file: {target_hash}", file=sys.stderr)
|
||||
# Delete old namespace tags we replaced (e.g., previous title:)
|
||||
if removed_tags:
|
||||
unique_removed = sorted(set(removed_tags))
|
||||
hydrus_client.delete_tags(target_hash, unique_removed, service_name)
|
||||
if tags_to_send:
|
||||
log(f"[add_tags] ✓ Tags added to Hydrus", file=sys.stderr)
|
||||
elif removed_tags:
|
||||
log(f"[add_tags] ✓ Removed {len(unique_removed)} tag(s) from Hydrus", file=sys.stderr)
|
||||
sidecar_count += 1
|
||||
if tags_to_send or removed_tags:
|
||||
total_modified += 1
|
||||
except Exception as e:
|
||||
log(f"[add_tags] Warning: Failed to add tags to Hydrus: {e}", file=sys.stderr)
|
||||
else:
|
||||
log(f"[add_tags] Warning: No hash available for Hydrus file, skipping", file=sys.stderr)
|
||||
elif storage_source and storage_source.lower() == 'local':
|
||||
# For local storage, save directly to DB (no sidecar needed)
|
||||
if file_path:
|
||||
library_root = get_local_storage_path(config)
|
||||
if library_root:
|
||||
try:
|
||||
path_obj = Path(file_path)
|
||||
with FolderDB(library_root) as db:
|
||||
db.save_tags(path_obj, existing_tags)
|
||||
# Reload tags to reflect DB state (preserves auto-title logic)
|
||||
file_hash = db.get_file_hash(path_obj)
|
||||
refreshed_tags = db.get_tags(file_hash) if file_hash else existing_tags
|
||||
# Recompute title from refreshed tags for accurate display
|
||||
refreshed_title = _extract_title_tag(refreshed_tags)
|
||||
if refreshed_title:
|
||||
_apply_title_to_result(res, refreshed_title)
|
||||
res_tags = refreshed_tags or existing_tags
|
||||
if isinstance(res, models.PipeObject):
|
||||
res.extra['tags'] = res_tags
|
||||
elif isinstance(res, dict):
|
||||
res['tags'] = res_tags
|
||||
log(f"[add_tags] Added {len(new_tags_added)} new tag(s); {len(res_tags)} total tag(s) stored locally", file=sys.stderr)
|
||||
sidecar_count += 1
|
||||
if new_tags_added or removed_tags:
|
||||
total_modified += 1
|
||||
final_tags = res_tags
|
||||
except Exception as e:
|
||||
log(f"[add_tags] Warning: Failed to save tags to local DB: {e}", file=sys.stderr)
|
||||
else:
|
||||
log(f"[add_tags] Warning: No library root configured for local storage, skipping", file=sys.stderr)
|
||||
else:
|
||||
log(f"[add_tags] Warning: No file path for local storage, skipping", file=sys.stderr)
|
||||
else:
|
||||
log(f"[add_tags] Warning: No file path for local storage, skipping", file=sys.stderr)
|
||||
else:
|
||||
# For other storage types or unknown sources, avoid writing sidecars to reduce clutter
|
||||
# (local/hydrus are handled above).
|
||||
# For other storage types or unknown sources, avoid writing sidecars to reduce clutter
|
||||
# (local/hydrus are handled above).
|
||||
ctx.emit(res)
|
||||
continue
|
||||
|
||||
# If title changed, refresh the cached result table so the display reflects the new name
|
||||
final_title = _extract_title_tag(final_tags)
|
||||
if final_title and (not original_title or final_title.lower() != original_title.lower()):
|
||||
_refresh_result_table_title(final_title, hydrus_hash or file_hash, file_hash, file_path)
|
||||
|
||||
# If tags changed, refresh tag view via get-tag (prefer current subject; fall back to hash refresh)
|
||||
if new_tags_added or removed_tags:
|
||||
_refresh_tags_view(res, hydrus_hash, file_hash, file_path, config)
|
||||
|
||||
# Emit the modified result
|
||||
ctx.emit(res)
|
||||
continue
|
||||
|
||||
# If title changed, refresh the cached result table so the display reflects the new name
|
||||
final_title = _extract_title_tag(final_tags)
|
||||
if final_title and (not original_title or final_title.lower() != original_title.lower()):
|
||||
_refresh_result_table_title(final_title, hydrus_hash or file_hash, file_hash, file_path)
|
||||
log(f"[add_tags] Added {total_new_tags} new tag(s) across {len(results)} item(s); modified {total_modified} item(s)", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
# If tags changed, refresh tag view via get-tag (prefer current subject; fall back to hash refresh)
|
||||
if new_tags_added or removed_tags:
|
||||
_refresh_tags_view(res, hydrus_hash, file_hash, file_path, config)
|
||||
|
||||
# Emit the modified result
|
||||
ctx.emit(res)
|
||||
|
||||
log(f"[add_tags] Added {total_new_tags} new tag(s) across {len(results)} item(s); modified {total_modified} item(s)", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="add-tags",
|
||||
summary="Add tags to a Hydrus file or write them to a local .tags sidecar.",
|
||||
usage="add-tags [-hash <sha256>] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]",
|
||||
args=[
|
||||
CmdletArg("-hash", type="string", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."),
|
||||
CmdletArg("-duplicate", type="string", description="Copy existing tag values to new namespaces. Formats: title:album,artist (explicit) or title,album,artist (inferred)"),
|
||||
CmdletArg("-list", type="string", description="Load predefined tag lists from adjective.json. Comma-separated list names (e.g., -list philosophy,occult)."),
|
||||
CmdletArg("--all", type="flag", description="Include temporary files in tagging (by default, only tags non-temporary files)."),
|
||||
CmdletArg("tags", type="string", required=False, description="One or more tags to add. Comma- or space-separated. Can also use {list_name} syntax. If omitted, uses tags from pipeline payload.", variadic=True),
|
||||
],
|
||||
details=[
|
||||
"- By default, only tags non-temporary files (from pipelines). Use --all to tag everything.",
|
||||
"- Without -hash and when the selection is a local file, tags are written to <file>.tags.",
|
||||
"- With a Hydrus hash, tags are sent to the 'my tags' service.",
|
||||
"- Multiple tags can be comma-separated or space-separated.",
|
||||
"- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult",
|
||||
"- Tags can also reference lists with curly braces: add-tag {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).",
|
||||
],
|
||||
)
|
||||
CMDLET = Add_Tag()
|
||||
@@ -1,170 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Sequence
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import register
|
||||
import models
|
||||
import pipeline as ctx
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash
|
||||
from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field, normalize_hash
|
||||
from helper.logger import log
|
||||
from config import get_local_storage_path
|
||||
from helper.local_library import LocalLibraryDB
|
||||
from helper.logger import debug
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="add-url",
|
||||
summary="Associate a URL with a file (Hydrus or Local).",
|
||||
usage="add-url [-hash <sha256>] <url>",
|
||||
args=[
|
||||
CmdletArg("-hash", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."),
|
||||
CmdletArg("url", required=True, description="The URL to associate with the file."),
|
||||
],
|
||||
details=[
|
||||
"- Adds the URL to the file's known URL list.",
|
||||
],
|
||||
)
|
||||
from helper.store import FileStorage
|
||||
|
||||
|
||||
@register(["add-url", "ass-url", "associate-url", "add_url"]) # aliases
|
||||
def add(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Help
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
class Add_Url(Cmdlet):
|
||||
"""Add URL associations to files via hash+store."""
|
||||
|
||||
NAME = "add-url"
|
||||
SUMMARY = "Associate a URL with a file"
|
||||
USAGE = "@1 | add-url <url>"
|
||||
ARGS = [
|
||||
SharedArgs.HASH,
|
||||
SharedArgs.STORE,
|
||||
CmdletArg("url", required=True, description="URL to associate"),
|
||||
]
|
||||
DETAIL = [
|
||||
"- Associates URL with file identified by hash+store",
|
||||
"- Multiple url can be comma-separated",
|
||||
]
|
||||
|
||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Add URL to file via hash+store backend."""
|
||||
parsed = parse_cmdlet_args(args, self)
|
||||
|
||||
# Extract hash and store from result or args
|
||||
file_hash = parsed.get("hash") or get_field(result, "hash")
|
||||
store_name = parsed.get("store") or get_field(result, "store")
|
||||
url_arg = parsed.get("url")
|
||||
|
||||
if not file_hash:
|
||||
log("Error: No file hash provided")
|
||||
return 1
|
||||
|
||||
if not store_name:
|
||||
log("Error: No store name provided")
|
||||
return 1
|
||||
|
||||
if not url_arg:
|
||||
log("Error: No URL provided")
|
||||
return 1
|
||||
|
||||
# Normalize hash
|
||||
file_hash = normalize_hash(file_hash)
|
||||
if not file_hash:
|
||||
log("Error: Invalid hash format")
|
||||
return 1
|
||||
|
||||
# Parse url (comma-separated)
|
||||
url = [u.strip() for u in str(url_arg).split(',') if u.strip()]
|
||||
if not url:
|
||||
log("Error: No valid url provided")
|
||||
return 1
|
||||
|
||||
# Get backend and add url
|
||||
try:
|
||||
storage = FileStorage(config)
|
||||
backend = storage[store_name]
|
||||
|
||||
for url in url:
|
||||
backend.add_url(file_hash, url)
|
||||
ctx.emit(f"Added URL: {url}")
|
||||
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from ._shared import parse_cmdlet_args
|
||||
parsed = parse_cmdlet_args(args, CMDLET)
|
||||
override_hash = parsed.get("hash")
|
||||
url_arg = parsed.get("url")
|
||||
|
||||
if not url_arg:
|
||||
log("Requires a URL argument")
|
||||
return 1
|
||||
|
||||
url_arg = str(url_arg).strip()
|
||||
if not url_arg:
|
||||
log("Requires a non-empty URL")
|
||||
return 1
|
||||
|
||||
# Split by comma to handle multiple URLs
|
||||
urls_to_add = [u.strip() for u in url_arg.split(',') if u.strip()]
|
||||
|
||||
# Handle @N selection which creates a list - extract the first item
|
||||
if isinstance(result, list) and len(result) > 0:
|
||||
result = result[0]
|
||||
|
||||
# Helper to get field from both dict and object
|
||||
def get_field(obj: Any, field: str, default: Any = None) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(field, default)
|
||||
else:
|
||||
return getattr(obj, field, default)
|
||||
|
||||
success = False
|
||||
|
||||
# 1. Try Local Library
|
||||
file_path = get_field(result, "file_path") or get_field(result, "path")
|
||||
if file_path and not override_hash:
|
||||
try:
|
||||
path_obj = Path(file_path)
|
||||
if path_obj.exists():
|
||||
storage_path = get_local_storage_path(config)
|
||||
if storage_path:
|
||||
with LocalLibraryDB(storage_path) as db:
|
||||
metadata = db.get_metadata(path_obj) or {}
|
||||
known_urls = metadata.get("known_urls") or []
|
||||
|
||||
local_changed = False
|
||||
for url in urls_to_add:
|
||||
if url not in known_urls:
|
||||
known_urls.append(url)
|
||||
local_changed = True
|
||||
ctx.emit(f"Associated URL with local file {path_obj.name}: {url}")
|
||||
else:
|
||||
ctx.emit(f"URL already exists for local file {path_obj.name}: {url}")
|
||||
|
||||
if local_changed:
|
||||
metadata["known_urls"] = known_urls
|
||||
# Ensure we have a hash if possible, but don't fail if not
|
||||
if not metadata.get("hash"):
|
||||
try:
|
||||
from helper.utils import sha256_file
|
||||
metadata["hash"] = sha256_file(path_obj)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db.save_metadata(path_obj, metadata)
|
||||
|
||||
success = True
|
||||
except Exception as e:
|
||||
log(f"Error updating local library: {e}", file=sys.stderr)
|
||||
|
||||
# 2. Try Hydrus
|
||||
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(get_field(result, "hash_hex", None))
|
||||
|
||||
if hash_hex:
|
||||
try:
|
||||
client = hydrus_wrapper.get_client(config)
|
||||
if client:
|
||||
for url in urls_to_add:
|
||||
client.associate_url(hash_hex, url)
|
||||
preview = hash_hex[:12] + ('…' if len(hash_hex) > 12 else '')
|
||||
ctx.emit(f"Associated URL with Hydrus file {preview}: {url}")
|
||||
success = True
|
||||
|
||||
except KeyError:
|
||||
log(f"Error: Storage backend '{store_name}' not configured")
|
||||
return 1
|
||||
except Exception as exc:
|
||||
# Only log error if we didn't succeed locally either
|
||||
if not success:
|
||||
log(f"Hydrus add-url failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
log(f"Error adding URL: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if success:
|
||||
# If we just mutated the currently displayed item, refresh URLs via get-url
|
||||
try:
|
||||
from cmdlets import get_url as get_url_cmd # type: ignore
|
||||
except Exception:
|
||||
get_url_cmd = None
|
||||
if get_url_cmd:
|
||||
try:
|
||||
subject = ctx.get_last_result_subject()
|
||||
if subject is not None:
|
||||
def norm(val: Any) -> str:
|
||||
return str(val).lower()
|
||||
target_hash = norm(hash_hex) if hash_hex else None
|
||||
target_path = norm(file_path) if 'file_path' in locals() else None
|
||||
subj_hashes = []
|
||||
subj_paths = []
|
||||
if isinstance(subject, dict):
|
||||
subj_hashes = [norm(v) for v in [subject.get("hydrus_hash"), subject.get("hash"), subject.get("hash_hex"), subject.get("file_hash")] if v]
|
||||
subj_paths = [norm(v) for v in [subject.get("file_path"), subject.get("path"), subject.get("target")] if v]
|
||||
else:
|
||||
subj_hashes = [norm(getattr(subject, f, None)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if getattr(subject, f, None)]
|
||||
subj_paths = [norm(getattr(subject, f, None)) for f in ("file_path", "path", "target") if getattr(subject, f, None)]
|
||||
is_match = False
|
||||
if target_hash and target_hash in subj_hashes:
|
||||
is_match = True
|
||||
if target_path and target_path in subj_paths:
|
||||
is_match = True
|
||||
if is_match:
|
||||
refresh_args: list[str] = []
|
||||
if hash_hex:
|
||||
refresh_args.extend(["-hash", hash_hex])
|
||||
get_url_cmd._run(subject, refresh_args, config)
|
||||
except Exception:
|
||||
debug("URL refresh skipped (error)")
|
||||
return 0
|
||||
|
||||
if not hash_hex and not file_path:
|
||||
log("Selected result does not include a file path or Hydrus hash", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 1
|
||||
|
||||
# Register cmdlet
|
||||
register(["add-url", "add_url"])(Add_Url)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -8,19 +8,19 @@ from helper.logger import log
|
||||
|
||||
from . import register
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash
|
||||
from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, should_show_help
|
||||
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="check-file-status",
|
||||
summary="Check if a file is active, deleted, or corrupted in Hydrus.",
|
||||
usage="check-file-status [-hash <sha256>]",
|
||||
args=[
|
||||
CmdletArg("-hash", description="File hash (SHA256) to check. If not provided, uses selected result."),
|
||||
arg=[
|
||||
SharedArgs.HASH,
|
||||
],
|
||||
details=[
|
||||
detail=[
|
||||
"- Shows whether file is active in Hydrus or marked as deleted",
|
||||
"- Detects corrupted data (e.g., comma-separated URLs)",
|
||||
"- Detects corrupted data (e.g., comma-separated url)",
|
||||
"- Displays file metadata and service locations",
|
||||
"- Note: Hydrus keeps deleted files for recovery. Use cleanup-corrupted for full removal.",
|
||||
],
|
||||
@@ -30,12 +30,9 @@ CMDLET = Cmdlet(
|
||||
@register(["check-file-status", "check-status", "file-status", "status"])
|
||||
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Help
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
if should_show_help(args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
# Parse arguments
|
||||
override_hash: str | None = None
|
||||
@@ -109,11 +106,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
log(f" - {sname} ({stype}) - deleted at {time_deleted}", file=sys.stderr)
|
||||
|
||||
# URL check
|
||||
urls = file_info.get("known_urls", [])
|
||||
log(f"\n🔗 URLs ({len(urls)}):", file=sys.stderr)
|
||||
url = file_info.get("url", [])
|
||||
log(f"\n🔗 url ({len(url)}):", file=sys.stderr)
|
||||
|
||||
corrupted_count = 0
|
||||
for i, url in enumerate(urls, 1):
|
||||
for i, url in enumerate(url, 1):
|
||||
if "," in url:
|
||||
corrupted_count += 1
|
||||
log(f" [{i}] ⚠️ CORRUPTED (comma-separated): {url[:50]}...", file=sys.stderr)
|
||||
|
||||
@@ -9,11 +9,12 @@ from __future__ import annotations
|
||||
from typing import Any, Dict, Sequence
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import json
|
||||
|
||||
from helper.logger import log
|
||||
|
||||
from . import register
|
||||
from ._shared import Cmdlet, CmdletArg, get_pipe_object_path, normalize_result_input, filter_results_by_temp
|
||||
from ._shared import Cmdlet, CmdletArg, get_pipe_object_path, normalize_result_input, filter_results_by_temp, should_show_help
|
||||
import models
|
||||
import pipeline as pipeline_context
|
||||
|
||||
@@ -36,13 +37,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""
|
||||
|
||||
# Help
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
|
||||
import json
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
if should_show_help(args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
# Normalize input to list
|
||||
results = normalize_result_input(result)
|
||||
@@ -97,8 +94,8 @@ CMDLET = Cmdlet(
|
||||
name="cleanup",
|
||||
summary="Remove temporary artifacts from pipeline (marked with is_temp=True).",
|
||||
usage="cleanup",
|
||||
args=[],
|
||||
details=[
|
||||
arg=[],
|
||||
detail=[
|
||||
"- Accepts pipeline results that may contain temporary files (screenshots, intermediate artifacts)",
|
||||
"- Deletes files marked with is_temp=True from disk",
|
||||
"- Also cleans up associated sidecar files (.tags, .metadata)",
|
||||
|
||||
@@ -1,398 +1,249 @@
|
||||
"""Delete-file cmdlet: Delete files from local storage and/or Hydrus."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Sequence
|
||||
import json
|
||||
import sys
|
||||
|
||||
from helper.logger import debug, log
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import models
|
||||
import pipeline as ctx
|
||||
from helper.logger import debug, log
|
||||
from helper.store import Folder
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash, looks_like_hash, get_origin, get_field, should_show_help
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash, looks_like_hash
|
||||
from config import get_local_storage_path
|
||||
from helper.local_library import LocalLibraryDB
|
||||
import pipeline as ctx
|
||||
|
||||
|
||||
def _refresh_last_search(config: Dict[str, Any]) -> None:
|
||||
"""Re-run the last search-file to refresh the table after deletes."""
|
||||
try:
|
||||
source_cmd = ctx.get_last_result_table_source_command() if hasattr(ctx, "get_last_result_table_source_command") else None
|
||||
if source_cmd not in {"search-file", "search_file", "search"}:
|
||||
return
|
||||
class Delete_File(Cmdlet):
|
||||
"""Class-based delete-file cmdlet with self-registration."""
|
||||
|
||||
args = ctx.get_last_result_table_source_args() if hasattr(ctx, "get_last_result_table_source_args") else []
|
||||
try:
|
||||
from cmdlets import search_file as search_file_cmd # type: ignore
|
||||
except Exception:
|
||||
return
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
name="delete-file",
|
||||
summary="Delete a file locally and/or from Hydrus, including database entries.",
|
||||
usage="delete-file [-hash <sha256>] [-conserve <local|hydrus>] [-lib-root <path>] [reason]",
|
||||
alias=["del-file"],
|
||||
arg=[
|
||||
CmdletArg("hash", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."),
|
||||
CmdletArg("conserve", description="Choose which copy to keep: 'local' or 'hydrus'."),
|
||||
CmdletArg("lib-root", description="Path to local library root for database cleanup."),
|
||||
CmdletArg("reason", description="Optional reason for deletion (free text)."),
|
||||
],
|
||||
detail=[
|
||||
"Default removes both the local file and Hydrus file.",
|
||||
"Use -conserve local to keep the local file, or -conserve hydrus to keep it in Hydrus.",
|
||||
"Database entries are automatically cleaned up for local files.",
|
||||
"Any remaining arguments are treated as the Hydrus reason text.",
|
||||
],
|
||||
exec=self.run,
|
||||
)
|
||||
self.register()
|
||||
|
||||
# Re-run the prior search to refresh items/table without disturbing history
|
||||
search_file_cmd._run(None, args, config)
|
||||
def _process_single_item(self, item: Any, override_hash: str | None, conserve: str | None,
|
||||
lib_root: str | None, reason: str, config: Dict[str, Any]) -> bool:
|
||||
"""Process deletion for a single item."""
|
||||
# Handle item as either dict or object
|
||||
if isinstance(item, dict):
|
||||
hash_hex_raw = item.get("hash_hex") or item.get("hash")
|
||||
target = item.get("target") or item.get("file_path") or item.get("path")
|
||||
else:
|
||||
hash_hex_raw = get_field(item, "hash_hex") or get_field(item, "hash")
|
||||
target = get_field(item, "target") or get_field(item, "file_path") or get_field(item, "path")
|
||||
|
||||
origin = get_origin(item)
|
||||
|
||||
# Also check the store field explicitly from PipeObject
|
||||
store = None
|
||||
if isinstance(item, dict):
|
||||
store = item.get("store")
|
||||
else:
|
||||
store = get_field(item, "store")
|
||||
|
||||
# For Hydrus files, the target IS the hash
|
||||
if origin and origin.lower() == "hydrus" and not hash_hex_raw:
|
||||
hash_hex_raw = target
|
||||
|
||||
# Set an overlay so action-command pipeline output displays the refreshed table
|
||||
try:
|
||||
new_table = ctx.get_last_result_table()
|
||||
new_items = ctx.get_last_result_items()
|
||||
subject = ctx.get_last_result_subject() if hasattr(ctx, "get_last_result_subject") else None
|
||||
if hasattr(ctx, "set_last_result_table_overlay") and new_table and new_items is not None:
|
||||
ctx.set_last_result_table_overlay(new_table, new_items, subject)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
debug(f"[delete_file] search refresh failed: {exc}", file=sys.stderr)
|
||||
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(hash_hex_raw)
|
||||
|
||||
|
||||
|
||||
|
||||
def _cleanup_relationships(db_path: Path, file_hash: str) -> int:
|
||||
"""Remove references to file_hash from other files' relationships."""
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
local_deleted = False
|
||||
local_target = isinstance(target, str) and target.strip() and not str(target).lower().startswith(("http://", "https://"))
|
||||
|
||||
# Find all metadata entries that contain this hash in relationships
|
||||
cursor.execute("SELECT file_id, relationships FROM metadata WHERE relationships LIKE ?", (f'%{file_hash}%',))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
rel_update_count = 0
|
||||
for row_fid, rel_json in rows:
|
||||
try:
|
||||
rels = json.loads(rel_json)
|
||||
changed = False
|
||||
if isinstance(rels, dict):
|
||||
for r_type, hashes in rels.items():
|
||||
if isinstance(hashes, list) and file_hash in hashes:
|
||||
hashes.remove(file_hash)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
cursor.execute("UPDATE metadata SET relationships = ? WHERE file_id = ?", (json.dumps(rels), row_fid))
|
||||
rel_update_count += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if rel_update_count > 0:
|
||||
debug(f"Removed relationship references from {rel_update_count} other files", file=sys.stderr)
|
||||
return rel_update_count
|
||||
except Exception as e:
|
||||
debug(f"Error cleaning up relationships: {e}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def _delete_database_entry(db_path: Path, file_path: str) -> bool:
|
||||
"""Delete file and related entries from local library database.
|
||||
|
||||
Args:
|
||||
db_path: Path to the library.db file
|
||||
file_path: Exact file path string as stored in database
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not db_path.exists():
|
||||
debug(f"Database not found at {db_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
debug(f"Searching database for file_path: {file_path}", file=sys.stderr)
|
||||
|
||||
# Find the file_id using the exact file_path
|
||||
cursor.execute('SELECT id FROM files WHERE file_path = ?', (file_path,))
|
||||
result = cursor.fetchone()
|
||||
|
||||
if not result:
|
||||
debug(f"File path not found in database: {file_path}", file=sys.stderr)
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
file_id = result[0]
|
||||
|
||||
# Get file hash before deletion to clean up relationships
|
||||
cursor.execute('SELECT file_hash FROM files WHERE id = ?', (file_id,))
|
||||
hash_result = cursor.fetchone()
|
||||
file_hash = hash_result[0] if hash_result else None
|
||||
|
||||
debug(f"Found file_id={file_id}, deleting all related records", file=sys.stderr)
|
||||
|
||||
# Delete related records
|
||||
cursor.execute('DELETE FROM metadata WHERE file_id = ?', (file_id,))
|
||||
meta_count = cursor.rowcount
|
||||
|
||||
cursor.execute('DELETE FROM tags WHERE file_id = ?', (file_id,))
|
||||
tags_count = cursor.rowcount
|
||||
|
||||
cursor.execute('DELETE FROM notes WHERE file_id = ?', (file_id,))
|
||||
notes_count = cursor.rowcount
|
||||
|
||||
cursor.execute('DELETE FROM files WHERE id = ?', (file_id,))
|
||||
files_count = cursor.rowcount
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Clean up relationships in other files
|
||||
if file_hash:
|
||||
_cleanup_relationships(db_path, file_hash)
|
||||
|
||||
debug(f"Deleted: metadata={meta_count}, tags={tags_count}, notes={notes_count}, files={files_count}", file=sys.stderr)
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
log(f"Database cleanup failed: {exc}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def _process_single_item(item: Any, override_hash: str | None, conserve: str | None,
|
||||
lib_root: str | None, reason: str, config: Dict[str, Any]) -> bool:
|
||||
"""Process deletion for a single item."""
|
||||
# Handle item as either dict or object
|
||||
if isinstance(item, dict):
|
||||
hash_hex_raw = item.get("hash_hex") or item.get("hash")
|
||||
target = item.get("target")
|
||||
origin = item.get("origin")
|
||||
else:
|
||||
hash_hex_raw = getattr(item, "hash_hex", None) or getattr(item, "hash", None)
|
||||
target = getattr(item, "target", None)
|
||||
origin = getattr(item, "origin", None)
|
||||
|
||||
# For Hydrus files, the target IS the hash
|
||||
if origin and origin.lower() == "hydrus" and not hash_hex_raw:
|
||||
hash_hex_raw = target
|
||||
|
||||
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(hash_hex_raw)
|
||||
|
||||
local_deleted = False
|
||||
local_target = isinstance(target, str) and target.strip() and not str(target).lower().startswith(("http://", "https://"))
|
||||
|
||||
# Try to resolve local path if target looks like a hash and we have a library root
|
||||
if local_target and looks_like_hash(str(target)) and lib_root:
|
||||
try:
|
||||
db_path = Path(lib_root) / ".downlow_library.db"
|
||||
if db_path.exists():
|
||||
# We can't use LocalLibraryDB context manager easily here without importing it,
|
||||
# but we can use a quick sqlite connection or just use the class if imported.
|
||||
# We imported LocalLibraryDB, so let's use it.
|
||||
with LocalLibraryDB(Path(lib_root)) as db:
|
||||
resolved = db.search_by_hash(str(target))
|
||||
if resolved:
|
||||
target = str(resolved)
|
||||
# Also ensure we have the hash set for Hydrus deletion if needed
|
||||
if not hash_hex:
|
||||
hash_hex = normalize_hash(str(target))
|
||||
except Exception as e:
|
||||
debug(f"Failed to resolve hash to local path: {e}", file=sys.stderr)
|
||||
|
||||
if conserve != "local" and local_target:
|
||||
path = Path(str(target))
|
||||
file_path_str = str(target) # Keep the original string for DB matching
|
||||
try:
|
||||
if path.exists() and path.is_file():
|
||||
path.unlink()
|
||||
local_deleted = True
|
||||
if ctx._PIPE_ACTIVE:
|
||||
ctx.emit(f"Removed local file: {path}")
|
||||
log(f"Deleted: {path.name}", file=sys.stderr)
|
||||
except Exception as exc:
|
||||
log(f"Local delete failed: {exc}", file=sys.stderr)
|
||||
|
||||
# Remove common sidecars regardless of file removal success
|
||||
for sidecar in (path.with_suffix(".tags"), path.with_suffix(".tags.txt"),
|
||||
path.with_suffix(".metadata"), path.with_suffix(".notes")):
|
||||
try:
|
||||
if sidecar.exists() and sidecar.is_file():
|
||||
sidecar.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clean up database entry if library root provided - do this regardless of file deletion success
|
||||
if lib_root:
|
||||
lib_root_path = Path(lib_root)
|
||||
db_path = lib_root_path / ".downlow_library.db"
|
||||
if conserve != "local" and local_target:
|
||||
path = Path(str(target))
|
||||
|
||||
# If file_path_str is a hash (because file was already deleted or target was hash),
|
||||
# we need to find the path by hash in the DB first
|
||||
if looks_like_hash(file_path_str):
|
||||
# If lib_root is provided and this is from a folder store, use the Folder class
|
||||
if lib_root:
|
||||
try:
|
||||
with LocalLibraryDB(lib_root_path) as db:
|
||||
resolved = db.search_by_hash(file_path_str)
|
||||
if resolved:
|
||||
file_path_str = str(resolved)
|
||||
folder = Folder(Path(lib_root), name=origin or "local")
|
||||
if folder.delete_file(str(path)):
|
||||
local_deleted = True
|
||||
ctx.emit(f"Removed file: {path.name}")
|
||||
log(f"Deleted: {path.name}", file=sys.stderr)
|
||||
except Exception as exc:
|
||||
debug(f"Folder.delete_file failed: {exc}", file=sys.stderr)
|
||||
# Fallback to manual deletion
|
||||
try:
|
||||
if path.exists() and path.is_file():
|
||||
path.unlink()
|
||||
local_deleted = True
|
||||
ctx.emit(f"Removed local file: {path}")
|
||||
log(f"Deleted: {path.name}", file=sys.stderr)
|
||||
except Exception as exc:
|
||||
log(f"Local delete failed: {exc}", file=sys.stderr)
|
||||
else:
|
||||
# No lib_root, just delete the file
|
||||
try:
|
||||
if path.exists() and path.is_file():
|
||||
path.unlink()
|
||||
local_deleted = True
|
||||
ctx.emit(f"Removed local file: {path}")
|
||||
log(f"Deleted: {path.name}", file=sys.stderr)
|
||||
except Exception as exc:
|
||||
log(f"Local delete failed: {exc}", file=sys.stderr)
|
||||
|
||||
# Remove common sidecars regardless of file removal success
|
||||
for sidecar in (path.with_suffix(".tags"), path.with_suffix(".tags.txt"),
|
||||
path.with_suffix(".metadata"), path.with_suffix(".notes")):
|
||||
try:
|
||||
if sidecar.exists() and sidecar.is_file():
|
||||
sidecar.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db_success = _delete_database_entry(db_path, file_path_str)
|
||||
|
||||
if not db_success:
|
||||
# If deletion failed (e.g. not found), but we have a hash, try to clean up relationships anyway
|
||||
effective_hash = None
|
||||
if looks_like_hash(file_path_str):
|
||||
effective_hash = file_path_str
|
||||
elif hash_hex:
|
||||
effective_hash = hash_hex
|
||||
|
||||
if effective_hash:
|
||||
debug(f"Entry not found, but attempting to clean up relationships for hash: {effective_hash}", file=sys.stderr)
|
||||
if _cleanup_relationships(db_path, effective_hash) > 0:
|
||||
db_success = True
|
||||
|
||||
if db_success:
|
||||
if ctx._PIPE_ACTIVE:
|
||||
ctx.emit(f"Removed database entry: {path.name}")
|
||||
debug(f"Database entry cleaned up", file=sys.stderr)
|
||||
local_deleted = True
|
||||
else:
|
||||
debug(f"Database entry not found or cleanup failed for {file_path_str}", file=sys.stderr)
|
||||
else:
|
||||
debug(f"No lib_root provided, skipping database cleanup", file=sys.stderr)
|
||||
|
||||
hydrus_deleted = False
|
||||
# Only attempt Hydrus deletion if origin is explicitly Hydrus or if we failed to delete locally
|
||||
# and we suspect it might be in Hydrus.
|
||||
# If origin is local, we should default to NOT deleting from Hydrus unless requested?
|
||||
# Or maybe we should check if it exists in Hydrus first?
|
||||
# The user complaint is "its still trying to delete hydrus, this is a local file".
|
||||
|
||||
should_try_hydrus = True
|
||||
if origin and origin.lower() == "local":
|
||||
should_try_hydrus = False
|
||||
|
||||
# If conserve is set to hydrus, definitely don't delete
|
||||
if conserve == "hydrus":
|
||||
hydrus_deleted = False
|
||||
# Only attempt Hydrus deletion if store is explicitly Hydrus-related
|
||||
# Check both origin and store fields to determine if this is a Hydrus file
|
||||
|
||||
should_try_hydrus = False
|
||||
|
||||
if should_try_hydrus and hash_hex:
|
||||
try:
|
||||
client = hydrus_wrapper.get_client(config)
|
||||
except Exception as exc:
|
||||
if not local_deleted:
|
||||
log(f"Hydrus client unavailable: {exc}", file=sys.stderr)
|
||||
return False
|
||||
else:
|
||||
if client is None:
|
||||
# Check if store indicates this is a Hydrus backend
|
||||
if store and ("hydrus" in store.lower() or store.lower() == "home" or store.lower() == "work"):
|
||||
should_try_hydrus = True
|
||||
# Fallback to origin check if store not available
|
||||
elif origin and origin.lower() == "hydrus":
|
||||
should_try_hydrus = True
|
||||
|
||||
# If conserve is set to hydrus, definitely don't delete
|
||||
if conserve == "hydrus":
|
||||
should_try_hydrus = False
|
||||
|
||||
if should_try_hydrus and hash_hex:
|
||||
try:
|
||||
client = hydrus_wrapper.get_client(config)
|
||||
except Exception as exc:
|
||||
if not local_deleted:
|
||||
# If we deleted locally, we don't care if Hydrus is unavailable
|
||||
pass
|
||||
else:
|
||||
log("Hydrus client unavailable", file=sys.stderr)
|
||||
log(f"Hydrus client unavailable: {exc}", file=sys.stderr)
|
||||
return False
|
||||
else:
|
||||
payload: Dict[str, Any] = {"hashes": [hash_hex]}
|
||||
if reason:
|
||||
payload["reason"] = reason
|
||||
try:
|
||||
client._post("/add_files/delete_files", data=payload) # type: ignore[attr-defined]
|
||||
hydrus_deleted = True
|
||||
preview = hash_hex[:12] + ('…' if len(hash_hex) > 12 else '')
|
||||
debug(f"Deleted from Hydrus: {preview}…", file=sys.stderr)
|
||||
except Exception as exc:
|
||||
# If it's not in Hydrus (e.g. 404 or similar), that's fine
|
||||
# log(f"Hydrus delete failed: {exc}", file=sys.stderr)
|
||||
if client is None:
|
||||
if not local_deleted:
|
||||
log("Hydrus client unavailable", file=sys.stderr)
|
||||
return False
|
||||
else:
|
||||
payload: Dict[str, Any] = {"hashes": [hash_hex]}
|
||||
if reason:
|
||||
payload["reason"] = reason
|
||||
try:
|
||||
client._post("/add_files/delete_files", data=payload) # type: ignore[attr-defined]
|
||||
hydrus_deleted = True
|
||||
preview = hash_hex[:12] + ('…' if len(hash_hex) > 12 else '')
|
||||
debug(f"Deleted from Hydrus: {preview}…", file=sys.stderr)
|
||||
except Exception as exc:
|
||||
# If it's not in Hydrus (e.g. 404 or similar), that's fine
|
||||
if not local_deleted:
|
||||
return False
|
||||
|
||||
if hydrus_deleted and hash_hex:
|
||||
preview = hash_hex[:12] + ('…' if len(hash_hex) > 12 else '')
|
||||
if ctx._PIPE_ACTIVE:
|
||||
if hydrus_deleted and hash_hex:
|
||||
preview = hash_hex[:12] + ('…' if len(hash_hex) > 12 else '')
|
||||
if reason:
|
||||
ctx.emit(f"Deleted {preview} (reason: {reason}).")
|
||||
else:
|
||||
ctx.emit(f"Deleted {preview}.")
|
||||
|
||||
if hydrus_deleted or local_deleted:
|
||||
return True
|
||||
if hydrus_deleted or local_deleted:
|
||||
return True
|
||||
|
||||
log("Selected result has neither Hydrus hash nor local file target")
|
||||
return False
|
||||
log("Selected result has neither Hydrus hash nor local file target")
|
||||
return False
|
||||
|
||||
|
||||
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Help
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Execute delete-file command."""
|
||||
if should_show_help(args):
|
||||
log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}")
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
override_hash: str | None = None
|
||||
conserve: str | None = None
|
||||
lib_root: str | None = None
|
||||
reason_tokens: list[str] = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
token = args[i]
|
||||
low = str(token).lower()
|
||||
if low in {"-hash", "--hash", "hash"} and i + 1 < len(args):
|
||||
override_hash = str(args[i + 1]).strip()
|
||||
i += 2
|
||||
continue
|
||||
if low in {"-conserve", "--conserve"} and i + 1 < len(args):
|
||||
value = str(args[i + 1]).strip().lower()
|
||||
if value in {"local", "hydrus"}:
|
||||
conserve = value
|
||||
# Parse arguments
|
||||
override_hash: str | None = None
|
||||
conserve: str | None = None
|
||||
lib_root: str | None = None
|
||||
reason_tokens: list[str] = []
|
||||
i = 0
|
||||
|
||||
while i < len(args):
|
||||
token = args[i]
|
||||
low = str(token).lower()
|
||||
if low in {"-hash", "--hash", "hash"} and i + 1 < len(args):
|
||||
override_hash = str(args[i + 1]).strip()
|
||||
i += 2
|
||||
continue
|
||||
if low in {"-lib-root", "--lib-root", "lib-root"} and i + 1 < len(args):
|
||||
lib_root = str(args[i + 1]).strip()
|
||||
i += 2
|
||||
continue
|
||||
reason_tokens.append(token)
|
||||
i += 1
|
||||
if low in {"-conserve", "--conserve"} and i + 1 < len(args):
|
||||
value = str(args[i + 1]).strip().lower()
|
||||
if value in {"local", "hydrus"}:
|
||||
conserve = value
|
||||
i += 2
|
||||
continue
|
||||
if low in {"-lib-root", "--lib-root", "lib-root"} and i + 1 < len(args):
|
||||
lib_root = str(args[i + 1]).strip()
|
||||
i += 2
|
||||
continue
|
||||
reason_tokens.append(token)
|
||||
i += 1
|
||||
|
||||
if not lib_root:
|
||||
# Try to get from config
|
||||
p = get_local_storage_path(config)
|
||||
if p:
|
||||
lib_root = str(p)
|
||||
# If no lib_root provided, try to get the first folder store from config
|
||||
if not lib_root:
|
||||
try:
|
||||
storage_config = config.get("storage", {})
|
||||
folder_config = storage_config.get("folder", {})
|
||||
if folder_config:
|
||||
# Get first folder store path
|
||||
for store_name, store_config in folder_config.items():
|
||||
if isinstance(store_config, dict):
|
||||
path = store_config.get("path")
|
||||
if path:
|
||||
lib_root = path
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
reason = " ".join(token for token in reason_tokens if str(token).strip()).strip()
|
||||
reason = " ".join(token for token in reason_tokens if str(token).strip()).strip()
|
||||
|
||||
items = []
|
||||
if isinstance(result, list):
|
||||
items = result
|
||||
elif result:
|
||||
items = [result]
|
||||
|
||||
if not items:
|
||||
log("No items to delete", file=sys.stderr)
|
||||
return 1
|
||||
items = []
|
||||
if isinstance(result, list):
|
||||
items = result
|
||||
elif result:
|
||||
items = [result]
|
||||
|
||||
if not items:
|
||||
log("No items to delete", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
success_count = 0
|
||||
for item in items:
|
||||
if _process_single_item(item, override_hash, conserve, lib_root, reason, config):
|
||||
success_count += 1
|
||||
success_count = 0
|
||||
for item in items:
|
||||
if self._process_single_item(item, override_hash, conserve, lib_root, reason, config):
|
||||
success_count += 1
|
||||
|
||||
if success_count > 0:
|
||||
_refresh_last_search(config)
|
||||
if success_count > 0:
|
||||
# Clear cached tables/items so deleted entries are not redisplayed
|
||||
try:
|
||||
ctx.set_last_result_table_overlay(None, None, None)
|
||||
ctx.set_last_result_table(None, [])
|
||||
ctx.set_last_result_items_only([])
|
||||
ctx.set_current_stage_table(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 0 if success_count > 0 else 1
|
||||
return 0 if success_count > 0 else 1
|
||||
|
||||
|
||||
# Instantiate and register the cmdlet
|
||||
Delete_File()
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="delete-file",
|
||||
summary="Delete a file locally and/or from Hydrus, including database entries.",
|
||||
usage="delete-file [-hash <sha256>] [-conserve <local|hydrus>] [-lib-root <path>] [reason]",
|
||||
aliases=["del-file"],
|
||||
args=[
|
||||
CmdletArg("hash", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."),
|
||||
CmdletArg("conserve", description="Choose which copy to keep: 'local' or 'hydrus'."),
|
||||
CmdletArg("lib-root", description="Path to local library root for database cleanup."),
|
||||
CmdletArg("reason", description="Optional reason for deletion (free text)."),
|
||||
],
|
||||
details=[
|
||||
"Default removes both the local file and Hydrus file.",
|
||||
"Use -conserve local to keep the local file, or -conserve hydrus to keep it in Hydrus.",
|
||||
"Database entries are automatically cleaned up for local files.",
|
||||
"Any remaining arguments are treated as the Hydrus reason text.",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -5,18 +5,18 @@ import json
|
||||
|
||||
import pipeline as ctx
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash, get_hash_for_operation, fetch_hydrus_metadata, should_show_help, get_field
|
||||
from helper.logger import log
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="delete-note",
|
||||
summary="Delete a named note from a Hydrus file.",
|
||||
usage="i | del-note [-hash <sha256>] <name>",
|
||||
aliases=["del-note"],
|
||||
args=[
|
||||
alias=["del-note"],
|
||||
arg=[
|
||||
|
||||
],
|
||||
details=[
|
||||
detail=[
|
||||
"- Removes the note with the given name from the Hydrus file.",
|
||||
],
|
||||
)
|
||||
@@ -24,12 +24,9 @@ CMDLET = Cmdlet(
|
||||
|
||||
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Help
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
if should_show_help(args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if not args:
|
||||
log("Requires the note name/key to delete")
|
||||
return 1
|
||||
@@ -57,7 +54,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if isinstance(result, list) and len(result) > 0:
|
||||
result = result[0]
|
||||
|
||||
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(getattr(result, "hash_hex", None))
|
||||
hash_hex = get_hash_for_operation(override_hash, result)
|
||||
if not hash_hex:
|
||||
log("Selected result does not include a Hydrus hash")
|
||||
return 1
|
||||
@@ -93,7 +90,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if isinstance(subject, dict):
|
||||
subj_hashes = [norm(v) for v in [subject.get("hydrus_hash"), subject.get("hash"), subject.get("hash_hex"), subject.get("file_hash")] if v]
|
||||
else:
|
||||
subj_hashes = [norm(getattr(subject, f, None)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if getattr(subject, f, None)]
|
||||
subj_hashes = [norm(get_field(subject, f)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if get_field(subject, f)]
|
||||
if target_hash and target_hash in subj_hashes:
|
||||
get_note_cmd.get_notes(subject, ["-hash", hash_hex], config)
|
||||
return 0
|
||||
|
||||
@@ -10,8 +10,8 @@ import sys
|
||||
from helper.logger import log
|
||||
|
||||
import pipeline as ctx
|
||||
from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args, normalize_result_input
|
||||
from helper.local_library import LocalLibrarySearchOptimizer
|
||||
from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args, normalize_result_input, get_field
|
||||
from helper.folder_store import LocalLibrarySearchOptimizer
|
||||
from config import get_local_storage_path
|
||||
|
||||
|
||||
@@ -35,12 +35,14 @@ def _refresh_relationship_view_if_current(target_hash: Optional[str], target_pat
|
||||
|
||||
subj_hashes: list[str] = []
|
||||
subj_paths: list[str] = []
|
||||
if isinstance(subject, dict):
|
||||
subj_hashes = [norm(v) for v in [subject.get("hydrus_hash"), subject.get("hash"), subject.get("hash_hex"), subject.get("file_hash")] if v]
|
||||
subj_paths = [norm(v) for v in [subject.get("file_path"), subject.get("path"), subject.get("target")] if v]
|
||||
else:
|
||||
subj_hashes = [norm(getattr(subject, f, None)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if getattr(subject, f, None)]
|
||||
subj_paths = [norm(getattr(subject, f, None)) for f in ("file_path", "path", "target") if getattr(subject, f, None)]
|
||||
for field in ("hydrus_hash", "hash", "hash_hex", "file_hash"):
|
||||
val = get_field(subject, field)
|
||||
if val:
|
||||
subj_hashes.append(norm(val))
|
||||
for field in ("file_path", "path", "target"):
|
||||
val = get_field(subject, field)
|
||||
if val:
|
||||
subj_paths.append(norm(val))
|
||||
|
||||
is_match = False
|
||||
if target_hashes and any(h in subj_hashes for h in target_hashes):
|
||||
@@ -93,21 +95,12 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
for single_result in results:
|
||||
try:
|
||||
# Get file path from result
|
||||
file_path_from_result = None
|
||||
|
||||
if isinstance(single_result, dict):
|
||||
file_path_from_result = (
|
||||
single_result.get("file_path") or
|
||||
single_result.get("path") or
|
||||
single_result.get("target")
|
||||
)
|
||||
else:
|
||||
file_path_from_result = (
|
||||
getattr(single_result, "file_path", None) or
|
||||
getattr(single_result, "path", None) or
|
||||
getattr(single_result, "target", None) or
|
||||
str(single_result)
|
||||
)
|
||||
file_path_from_result = (
|
||||
get_field(single_result, "file_path")
|
||||
or get_field(single_result, "path")
|
||||
or get_field(single_result, "target")
|
||||
or (str(single_result) if not isinstance(single_result, dict) else None)
|
||||
)
|
||||
|
||||
if not file_path_from_result:
|
||||
log("Could not extract file path from result", file=sys.stderr)
|
||||
@@ -199,12 +192,12 @@ CMDLET = Cmdlet(
|
||||
name="delete-relationship",
|
||||
summary="Remove relationships from files.",
|
||||
usage="@1 | delete-relationship --all OR delete-relationship -path <file> --all OR @1-3 | delete-relationship -type alt",
|
||||
args=[
|
||||
arg=[
|
||||
CmdletArg("path", type="string", description="Specify the local file path (if not piping a result)."),
|
||||
CmdletArg("all", type="flag", description="Delete all relationships for the file(s)."),
|
||||
CmdletArg("type", type="string", description="Delete specific relationship type ('alt', 'king', 'related'). Default: delete all types."),
|
||||
],
|
||||
details=[
|
||||
detail=[
|
||||
"- Delete all relationships: pipe files | delete-relationship --all",
|
||||
"- Delete specific type: pipe files | delete-relationship -type alt",
|
||||
"- Delete all from file: delete-relationship -path <file> --all",
|
||||
|
||||
@@ -9,7 +9,7 @@ from . import register
|
||||
import models
|
||||
import pipeline as ctx
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash, parse_tag_arguments
|
||||
from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, parse_tag_arguments, fetch_hydrus_metadata, should_show_help, get_field
|
||||
from helper.logger import debug, log
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ def _refresh_tag_view_if_current(hash_hex: str | None, file_path: str | None, co
|
||||
subj_hashes = [norm(v) for v in [subject.get("hydrus_hash"), subject.get("hash"), subject.get("hash_hex"), subject.get("file_hash")] if v]
|
||||
subj_paths = [norm(v) for v in [subject.get("file_path"), subject.get("path"), subject.get("target")] if v]
|
||||
else:
|
||||
subj_hashes = [norm(getattr(subject, f, None)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if getattr(subject, f, None)]
|
||||
subj_paths = [norm(getattr(subject, f, None)) for f in ("file_path", "path", "target") if getattr(subject, f, None)]
|
||||
subj_hashes = [norm(get_field(subject, f)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if get_field(subject, f)]
|
||||
subj_paths = [norm(get_field(subject, f)) for f in ("file_path", "path", "target") if get_field(subject, f)]
|
||||
|
||||
is_match = False
|
||||
if target_hash and target_hash in subj_hashes:
|
||||
@@ -60,12 +60,12 @@ CMDLET = Cmdlet(
|
||||
name="delete-tags",
|
||||
summary="Remove tags from a Hydrus file.",
|
||||
usage="del-tags [-hash <sha256>] <tag>[,<tag>...]",
|
||||
aliases=["del-tag", "del-tags", "delete-tag"],
|
||||
args=[
|
||||
CmdletArg("-hash", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."),
|
||||
alias=["del-tag", "del-tags", "delete-tag"],
|
||||
arg=[
|
||||
SharedArgs.HASH,
|
||||
CmdletArg("<tag>[,<tag>...]", required=True, description="One or more tags to remove. Comma- or space-separated."),
|
||||
],
|
||||
details=[
|
||||
detail=[
|
||||
"- Requires a Hydrus file (hash present) or explicit -hash override.",
|
||||
"- Multiple tags can be comma-separated or space-separated.",
|
||||
],
|
||||
@@ -74,12 +74,9 @@ CMDLET = Cmdlet(
|
||||
@register(["del-tag", "del-tags", "delete-tag", "delete-tags"]) # Still needed for backward compatibility
|
||||
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Help
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
if should_show_help(args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
# Check if we have a piped TagItem with no args (i.e., from @1 | delete-tag)
|
||||
has_piped_tag = (result and hasattr(result, '__class__') and
|
||||
@@ -139,15 +136,15 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if idx - 1 < len(ctx._LAST_RESULT_ITEMS):
|
||||
item = ctx._LAST_RESULT_ITEMS[idx - 1]
|
||||
if hasattr(item, '__class__') and item.__class__.__name__ == 'TagItem':
|
||||
tag_name = getattr(item, 'tag_name', None)
|
||||
tag_name = get_field(item, 'tag_name')
|
||||
if tag_name:
|
||||
log(f"[delete_tag] Extracted tag from @{idx}: {tag_name}")
|
||||
tags_from_at_syntax.append(tag_name)
|
||||
# Also get hash from first item for consistency
|
||||
if not hash_from_at_syntax:
|
||||
hash_from_at_syntax = getattr(item, 'hash_hex', None)
|
||||
hash_from_at_syntax = get_field(item, 'hash_hex')
|
||||
if not file_path_from_at_syntax:
|
||||
file_path_from_at_syntax = getattr(item, 'file_path', None)
|
||||
file_path_from_at_syntax = get_field(item, 'file_path')
|
||||
|
||||
if not tags_from_at_syntax:
|
||||
log(f"No tags found at indices: {indices}")
|
||||
@@ -219,13 +216,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
for item in items_to_process:
|
||||
tags_to_delete = []
|
||||
item_hash = normalize_hash(override_hash) if override_hash else normalize_hash(getattr(item, "hash_hex", None))
|
||||
item_path = getattr(item, "path", None) or getattr(item, "file_path", None) or getattr(item, "target", None)
|
||||
# If result is a dict (e.g. from search-file), try getting path from keys
|
||||
if not item_path and isinstance(item, dict):
|
||||
item_path = item.get("path") or item.get("file_path") or item.get("target")
|
||||
|
||||
item_source = getattr(item, "source", None)
|
||||
item_hash = normalize_hash(override_hash) if override_hash else normalize_hash(get_field(item, "hash_hex"))
|
||||
item_path = (
|
||||
get_field(item, "path")
|
||||
or get_field(item, "file_path")
|
||||
or get_field(item, "target")
|
||||
)
|
||||
item_source = get_field(item, "source")
|
||||
|
||||
if hasattr(item, '__class__') and item.__class__.__name__ == 'TagItem':
|
||||
# It's a TagItem
|
||||
@@ -238,7 +235,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Let's assume if args are present, we use args. If not, we use the tag name.
|
||||
tags_to_delete = tags_arg
|
||||
else:
|
||||
tag_name = getattr(item, 'tag_name', None)
|
||||
tag_name = get_field(item, 'tag_name')
|
||||
if tag_name:
|
||||
tags_to_delete = [tag_name]
|
||||
else:
|
||||
@@ -270,34 +267,31 @@ def _process_deletion(tags: list[str], hash_hex: str | None, file_path: str | No
|
||||
# Prefer local DB when we have a path and not explicitly hydrus
|
||||
if file_path and (source == "local" or (source != "hydrus" and not hash_hex)):
|
||||
try:
|
||||
from helper.local_library import LocalLibraryDB
|
||||
from helper.folder_store import FolderDB
|
||||
from config import get_local_storage_path
|
||||
path_obj = Path(file_path)
|
||||
local_root = get_local_storage_path(config) or path_obj.parent
|
||||
with LocalLibraryDB(local_root) as db:
|
||||
existing = db.get_tags(path_obj) or []
|
||||
with FolderDB(local_root) as db:
|
||||
file_hash = db.get_file_hash(path_obj)
|
||||
existing = db.get_tags(file_hash) if file_hash else []
|
||||
except Exception:
|
||||
existing = []
|
||||
elif hash_hex:
|
||||
try:
|
||||
client = hydrus_wrapper.get_client(config)
|
||||
payload = client.fetch_file_metadata(
|
||||
hashes=[hash_hex],
|
||||
include_service_keys_to_tags=True,
|
||||
include_file_urls=False,
|
||||
)
|
||||
items = payload.get("metadata") if isinstance(payload, dict) else None
|
||||
meta = items[0] if isinstance(items, list) and items else None
|
||||
if isinstance(meta, dict):
|
||||
tags_payload = meta.get("tags")
|
||||
if isinstance(tags_payload, dict):
|
||||
seen: set[str] = set()
|
||||
for svc_data in tags_payload.values():
|
||||
if not isinstance(svc_data, dict):
|
||||
continue
|
||||
display = svc_data.get("display_tags")
|
||||
if isinstance(display, list):
|
||||
for t in display:
|
||||
meta, _ = fetch_hydrus_metadata(
|
||||
config, hash_hex,
|
||||
include_service_keys_to_tags=True,
|
||||
include_file_url=False,
|
||||
)
|
||||
if isinstance(meta, dict):
|
||||
tags_payload = meta.get("tags")
|
||||
if isinstance(tags_payload, dict):
|
||||
seen: set[str] = set()
|
||||
for svc_data in tags_payload.values():
|
||||
if not isinstance(svc_data, dict):
|
||||
continue
|
||||
display = svc_data.get("display_tags")
|
||||
if isinstance(display, list):
|
||||
for t in display:
|
||||
if isinstance(t, (str, bytes)):
|
||||
val = str(t).strip()
|
||||
if val and val not in seen:
|
||||
@@ -313,8 +307,6 @@ def _process_deletion(tags: list[str], hash_hex: str | None, file_path: str | No
|
||||
if val and val not in seen:
|
||||
seen.add(val)
|
||||
existing.append(val)
|
||||
except Exception:
|
||||
existing = []
|
||||
return existing
|
||||
|
||||
# Safety: only block if this deletion would remove the final title tag
|
||||
@@ -335,7 +327,7 @@ def _process_deletion(tags: list[str], hash_hex: str | None, file_path: str | No
|
||||
# Handle local file tag deletion
|
||||
if file_path and (source == "local" or (not hash_hex and source != "hydrus")):
|
||||
try:
|
||||
from helper.local_library import LocalLibraryDB
|
||||
from helper.folder_store import FolderDB
|
||||
from pathlib import Path
|
||||
|
||||
path_obj = Path(file_path)
|
||||
@@ -351,7 +343,7 @@ def _process_deletion(tags: list[str], hash_hex: str | None, file_path: str | No
|
||||
# Fallback: assume file is in a library root or use its parent
|
||||
local_root = path_obj.parent
|
||||
|
||||
with LocalLibraryDB(local_root) as db:
|
||||
with FolderDB(local_root) as db:
|
||||
db.remove_tags(path_obj, tags)
|
||||
debug(f"Removed {len(tags)} tag(s) from {path_obj.name} (local)")
|
||||
_refresh_tag_view_if_current(hash_hex, file_path, config)
|
||||
|
||||
@@ -1,194 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Sequence
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import register
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash
|
||||
from helper.logger import debug, log
|
||||
from config import get_local_storage_path
|
||||
from helper.local_library import LocalLibraryDB
|
||||
import pipeline as ctx
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="delete-url",
|
||||
summary="Remove a URL association from a file (Hydrus or Local).",
|
||||
usage="delete-url [-hash <sha256>] <url>",
|
||||
args=[
|
||||
CmdletArg("-hash", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."),
|
||||
CmdletArg("url", required=True, description="The URL to remove from the file."),
|
||||
],
|
||||
details=[
|
||||
"- Removes the URL from the file's known URL list.",
|
||||
],
|
||||
)
|
||||
from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field, normalize_hash
|
||||
from helper.logger import log
|
||||
from helper.store import FileStorage
|
||||
|
||||
|
||||
def _parse_hash_and_rest(args: Sequence[str]) -> tuple[str | None, list[str]]:
|
||||
override_hash: str | None = None
|
||||
rest: list[str] = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
a = args[i]
|
||||
low = str(a).lower()
|
||||
if low in {"-hash", "--hash", "hash"} and i + 1 < len(args):
|
||||
override_hash = str(args[i + 1]).strip()
|
||||
i += 2
|
||||
continue
|
||||
rest.append(a)
|
||||
i += 1
|
||||
return override_hash, rest
|
||||
|
||||
|
||||
@register(["del-url", "delete-url", "delete_url"]) # aliases
|
||||
def delete(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Help
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
class Delete_Url(Cmdlet):
|
||||
"""Delete URL associations from files via hash+store."""
|
||||
|
||||
override_hash, rest = _parse_hash_and_rest(args)
|
||||
NAME = "delete-url"
|
||||
SUMMARY = "Remove a URL association from a file"
|
||||
USAGE = "@1 | delete-url <url>"
|
||||
ARGS = [
|
||||
SharedArgs.HASH,
|
||||
SharedArgs.STORE,
|
||||
CmdletArg("url", required=True, description="URL to remove"),
|
||||
]
|
||||
DETAIL = [
|
||||
"- Removes URL association from file identified by hash+store",
|
||||
"- Multiple url can be comma-separated",
|
||||
]
|
||||
|
||||
url_arg = None
|
||||
if rest:
|
||||
url_arg = str(rest[0] or '').strip()
|
||||
|
||||
# Normalize result to a list
|
||||
items = result if isinstance(result, list) else [result]
|
||||
if not items:
|
||||
log("No input provided.")
|
||||
return 1
|
||||
|
||||
success_count = 0
|
||||
|
||||
for item in items:
|
||||
target_url = url_arg
|
||||
target_file = item
|
||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Delete URL from file via hash+store backend."""
|
||||
parsed = parse_cmdlet_args(args, self)
|
||||
|
||||
# Check for rich URL object from get-url
|
||||
if isinstance(item, dict) and "url" in item and "source_file" in item:
|
||||
if not target_url:
|
||||
target_url = item["url"]
|
||||
target_file = item["source_file"]
|
||||
# Extract hash and store from result or args
|
||||
file_hash = parsed.get("hash") or get_field(result, "hash")
|
||||
store_name = parsed.get("store") or get_field(result, "store")
|
||||
url_arg = parsed.get("url")
|
||||
|
||||
if not target_url:
|
||||
continue
|
||||
|
||||
if _delete_single(target_file, target_url, override_hash, config):
|
||||
success_count += 1
|
||||
|
||||
if success_count == 0:
|
||||
if not file_hash:
|
||||
log("Error: No file hash provided")
|
||||
return 1
|
||||
|
||||
if not store_name:
|
||||
log("Error: No store name provided")
|
||||
return 1
|
||||
|
||||
if not url_arg:
|
||||
log("Requires a URL argument or valid selection.")
|
||||
else:
|
||||
log("Failed to delete URL(s).")
|
||||
return 1
|
||||
log("Error: No URL provided")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _delete_single(result: Any, url: str, override_hash: str | None, config: Dict[str, Any]) -> bool:
|
||||
# Helper to get field from both dict and object
|
||||
def get_field(obj: Any, field: str, default: Any = None) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(field, default)
|
||||
else:
|
||||
return getattr(obj, field, default)
|
||||
|
||||
success = False
|
||||
|
||||
# 1. Try Local Library
|
||||
file_path = get_field(result, "file_path") or get_field(result, "path")
|
||||
if file_path and not override_hash:
|
||||
# Normalize hash
|
||||
file_hash = normalize_hash(file_hash)
|
||||
if not file_hash:
|
||||
log("Error: Invalid hash format")
|
||||
return 1
|
||||
|
||||
# Parse url (comma-separated)
|
||||
url = [u.strip() for u in str(url_arg).split(',') if u.strip()]
|
||||
if not url:
|
||||
log("Error: No valid url provided")
|
||||
return 1
|
||||
|
||||
# Get backend and delete url
|
||||
try:
|
||||
path_obj = Path(file_path)
|
||||
if path_obj.exists():
|
||||
storage_path = get_local_storage_path(config)
|
||||
if storage_path:
|
||||
with LocalLibraryDB(storage_path) as db:
|
||||
metadata = db.get_metadata(path_obj) or {}
|
||||
known_urls = metadata.get("known_urls") or []
|
||||
|
||||
# Handle comma-separated URLs if passed as arg
|
||||
# But first check if the exact url string exists (e.g. if it contains commas itself)
|
||||
urls_to_process = []
|
||||
if url in known_urls:
|
||||
urls_to_process = [url]
|
||||
else:
|
||||
urls_to_process = [u.strip() for u in url.split(',') if u.strip()]
|
||||
|
||||
local_changed = False
|
||||
for u in urls_to_process:
|
||||
if u in known_urls:
|
||||
known_urls.remove(u)
|
||||
local_changed = True
|
||||
ctx.emit(f"Deleted URL from local file {path_obj.name}: {u}")
|
||||
|
||||
if local_changed:
|
||||
metadata["known_urls"] = known_urls
|
||||
db.save_metadata(path_obj, metadata)
|
||||
success = True
|
||||
except Exception as e:
|
||||
log(f"Error updating local library: {e}", file=sys.stderr)
|
||||
|
||||
# 2. Try Hydrus
|
||||
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(get_field(result, "hash_hex", None))
|
||||
|
||||
if hash_hex:
|
||||
try:
|
||||
client = hydrus_wrapper.get_client(config)
|
||||
if client:
|
||||
urls_to_delete = [u.strip() for u in url.split(',') if u.strip()]
|
||||
for u in urls_to_delete:
|
||||
client.delete_url(hash_hex, u)
|
||||
preview = hash_hex[:12] + ('…' if len(hash_hex) > 12 else '')
|
||||
ctx.emit(f"Deleted URL from Hydrus file {preview}: {u}")
|
||||
success = True
|
||||
storage = FileStorage(config)
|
||||
backend = storage[store_name]
|
||||
|
||||
for url in url:
|
||||
backend.delete_url(file_hash, url)
|
||||
ctx.emit(f"Deleted URL: {url}")
|
||||
|
||||
return 0
|
||||
|
||||
except KeyError:
|
||||
log(f"Error: Storage backend '{store_name}' not configured")
|
||||
return 1
|
||||
except Exception as exc:
|
||||
log(f"Hydrus del-url failed: {exc}", file=sys.stderr)
|
||||
log(f"Error deleting URL: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if success:
|
||||
try:
|
||||
from cmdlets import get_url as get_url_cmd # type: ignore
|
||||
except Exception:
|
||||
get_url_cmd = None
|
||||
if get_url_cmd:
|
||||
try:
|
||||
subject = ctx.get_last_result_subject()
|
||||
if subject is not None:
|
||||
def norm(val: Any) -> str:
|
||||
return str(val).lower()
|
||||
|
||||
target_hash = norm(hash_hex) if hash_hex else None
|
||||
target_path = norm(file_path) if file_path else None
|
||||
|
||||
subj_hashes = []
|
||||
subj_paths = []
|
||||
if isinstance(subject, dict):
|
||||
subj_hashes = [norm(v) for v in [subject.get("hydrus_hash"), subject.get("hash"), subject.get("hash_hex"), subject.get("file_hash")] if v]
|
||||
subj_paths = [norm(v) for v in [subject.get("file_path"), subject.get("path"), subject.get("target")] if v]
|
||||
else:
|
||||
subj_hashes = [norm(getattr(subject, f, None)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if getattr(subject, f, None)]
|
||||
subj_paths = [norm(getattr(subject, f, None)) for f in ("file_path", "path", "target") if getattr(subject, f, None)]
|
||||
|
||||
is_match = False
|
||||
if target_hash and target_hash in subj_hashes:
|
||||
is_match = True
|
||||
if target_path and target_path in subj_paths:
|
||||
is_match = True
|
||||
|
||||
if is_match:
|
||||
refresh_args: list[str] = []
|
||||
if hash_hex:
|
||||
refresh_args.extend(["-hash", hash_hex])
|
||||
get_url_cmd._run(subject, refresh_args, config)
|
||||
except Exception:
|
||||
debug("URL refresh skipped (error)")
|
||||
|
||||
return success
|
||||
# Register cmdlet
|
||||
register(["delete-url", "del-url", "delete_url"])(Delete_Url)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
199
cmdlets/download_file.py
Normal file
199
cmdlets/download_file.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Download files directly via HTTP (non-yt-dlp url).
|
||||
|
||||
Focused cmdlet for direct file downloads from:
|
||||
- PDFs, images, documents
|
||||
- url not supported by yt-dlp
|
||||
- LibGen sources
|
||||
- Direct file links
|
||||
|
||||
No streaming site logic - pure HTTP download with retries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from helper.download import DownloadError, _download_direct_file
|
||||
from helper.logger import log, debug
|
||||
from models import DownloadOptions
|
||||
import pipeline as pipeline_context
|
||||
|
||||
from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, register_url_with_local_library, coerce_to_pipe_object
|
||||
|
||||
|
||||
class Download_File(Cmdlet):
|
||||
"""Class-based download-file cmdlet - direct HTTP downloads."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize download-file cmdlet."""
|
||||
super().__init__(
|
||||
name="download-file",
|
||||
summary="Download files directly via HTTP (PDFs, images, documents)",
|
||||
usage="download-file <url> [options] or search-file | download-file [options]",
|
||||
alias=["dl-file", "download-http"],
|
||||
arg=[
|
||||
CmdletArg(name="url", type="string", required=False, description="URL to download (direct file links)", variadic=True),
|
||||
CmdletArg(name="-url", type="string", description="URL to download (alias for positional argument)", variadic=True),
|
||||
CmdletArg(name="output", type="string", alias="o", description="Output filename (auto-detected if not specified)"),
|
||||
SharedArgs.URL
|
||||
],
|
||||
detail=["Download files directly via HTTP without yt-dlp processing.", "For streaming sites, use download-media."],
|
||||
exec=self.run,
|
||||
)
|
||||
self.register()
|
||||
|
||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Main execution method."""
|
||||
stage_ctx = pipeline_context.get_stage_context()
|
||||
in_pipeline = stage_ctx is not None and getattr(stage_ctx, "total_stages", 1) > 1
|
||||
if in_pipeline and isinstance(config, dict):
|
||||
config["_quiet_background_output"] = True
|
||||
return self._run_impl(result, args, config)
|
||||
|
||||
def _run_impl(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Main download implementation for direct HTTP files."""
|
||||
try:
|
||||
debug("Starting download-file")
|
||||
|
||||
# Parse arguments
|
||||
parsed = parse_cmdlet_args(args, self)
|
||||
|
||||
# Extract options
|
||||
raw_url = parsed.get("url", [])
|
||||
if isinstance(raw_url, str):
|
||||
raw_url = [raw_url]
|
||||
|
||||
if not raw_url:
|
||||
log("No url to download", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Get output directory
|
||||
final_output_dir = self._resolve_output_dir(parsed, config)
|
||||
if not final_output_dir:
|
||||
return 1
|
||||
|
||||
debug(f"Output directory: {final_output_dir}")
|
||||
|
||||
# Download each URL
|
||||
downloaded_count = 0
|
||||
quiet_mode = bool(config.get("_quiet_background_output")) if isinstance(config, dict) else False
|
||||
custom_output = parsed.get("output")
|
||||
|
||||
for url in raw_url:
|
||||
try:
|
||||
debug(f"Processing: {url}")
|
||||
|
||||
# Direct HTTP download
|
||||
result_obj = _download_direct_file(url, final_output_dir, quiet=quiet_mode)
|
||||
debug(f"Download completed, building pipe object...")
|
||||
pipe_obj_dict = self._build_pipe_object(result_obj, url, final_output_dir)
|
||||
debug(f"Emitting result to pipeline...")
|
||||
pipeline_context.emit(pipe_obj_dict)
|
||||
|
||||
# Automatically register url with local library
|
||||
if pipe_obj_dict.get("url"):
|
||||
pipe_obj = coerce_to_pipe_object(pipe_obj_dict)
|
||||
register_url_with_local_library(pipe_obj, config)
|
||||
|
||||
downloaded_count += 1
|
||||
debug("✓ Downloaded and emitted")
|
||||
|
||||
except DownloadError as e:
|
||||
log(f"Download failed for {url}: {e}", file=sys.stderr)
|
||||
except Exception as e:
|
||||
log(f"Error processing {url}: {e}", file=sys.stderr)
|
||||
|
||||
if downloaded_count > 0:
|
||||
debug(f"✓ Successfully processed {downloaded_count} file(s)")
|
||||
return 0
|
||||
|
||||
log("No downloads completed", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
except Exception as e:
|
||||
log(f"Error in download-file: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
def _resolve_output_dir(self, parsed: Dict[str, Any], config: Dict[str, Any]) -> Optional[Path]:
|
||||
"""Resolve the output directory from storage location or config."""
|
||||
storage_location = parsed.get("storage")
|
||||
|
||||
# Priority 1: --storage flag
|
||||
if storage_location:
|
||||
try:
|
||||
return SharedArgs.resolve_storage(storage_location)
|
||||
except Exception as e:
|
||||
log(f"Invalid storage location: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Priority 2: Config outfile
|
||||
if config and config.get("outfile"):
|
||||
try:
|
||||
return Path(config["outfile"]).expanduser()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Priority 3: Default (home/Downloads)
|
||||
final_output_dir = Path.home() / "Downloads"
|
||||
debug(f"Using default directory: {final_output_dir}")
|
||||
|
||||
# Ensure directory exists
|
||||
try:
|
||||
final_output_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
log(f"Cannot create output directory {final_output_dir}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
return final_output_dir
|
||||
|
||||
def _build_pipe_object(self, download_result: Any, url: str, output_dir: Path) -> Dict[str, Any]:
|
||||
"""Create a PipeObject-compatible dict from a download result."""
|
||||
# Try to get file path from result
|
||||
file_path = None
|
||||
if hasattr(download_result, 'path'):
|
||||
file_path = download_result.path
|
||||
elif isinstance(download_result, dict) and 'path' in download_result:
|
||||
file_path = download_result['path']
|
||||
|
||||
if not file_path:
|
||||
# Fallback: assume result is the path itself
|
||||
file_path = str(download_result)
|
||||
|
||||
media_path = Path(file_path)
|
||||
hash_value = self._compute_file_hash(media_path)
|
||||
title = media_path.stem
|
||||
|
||||
# Build tags with title for searchability
|
||||
tags = [f"title:{title}"]
|
||||
|
||||
# Prefer canonical fields while keeping legacy keys for compatibility
|
||||
return {
|
||||
"path": str(media_path),
|
||||
"hash": hash_value,
|
||||
"file_hash": hash_value,
|
||||
"title": title,
|
||||
"file_title": title,
|
||||
"action": "cmdlet:download-file",
|
||||
"download_mode": "file",
|
||||
"url": url or (download_result.get('url') if isinstance(download_result, dict) else None),
|
||||
"url": [url] if url else [],
|
||||
"store": "local",
|
||||
"storage_source": "downloads",
|
||||
"media_kind": "file",
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
def _compute_file_hash(self, filepath: Path) -> str:
|
||||
"""Compute SHA256 hash of a file."""
|
||||
import hashlib
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(filepath, "rb") as f:
|
||||
for byte_block in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
|
||||
# Module-level singleton registration
|
||||
CMDLET = Download_File()
|
||||
1445
cmdlets/download_media.py
Normal file
1445
cmdlets/download_media.py
Normal file
File diff suppressed because it is too large
Load Diff
127
cmdlets/download_torrent.py
Normal file
127
cmdlets/download_torrent.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Download torrent/magnet links via AllDebrid in a dedicated cmdlet.
|
||||
|
||||
Features:
|
||||
- Accepts magnet links and .torrent files/url
|
||||
- Uses AllDebrid API for background downloads
|
||||
- Progress tracking and worker management
|
||||
- Self-registering class-based cmdlet
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import uuid
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
|
||||
from helper.logger import log
|
||||
from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args
|
||||
|
||||
class Download_Torrent(Cmdlet):
|
||||
"""Class-based download-torrent cmdlet with self-registration."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
name="download-torrent",
|
||||
summary="Download torrent/magnet links via AllDebrid",
|
||||
usage="download-torrent <magnet|.torrent> [options]",
|
||||
alias=["torrent", "magnet"],
|
||||
arg=[
|
||||
CmdletArg(name="magnet", type="string", required=False, description="Magnet link or .torrent file/URL", variadic=True),
|
||||
CmdletArg(name="output", type="string", description="Output directory for downloaded files"),
|
||||
CmdletArg(name="wait", type="float", description="Wait time (seconds) for magnet processing timeout"),
|
||||
CmdletArg(name="background", type="flag", alias="bg", description="Start download in background"),
|
||||
],
|
||||
detail=["Download torrents/magnets via AllDebrid API."],
|
||||
exec=self.run,
|
||||
)
|
||||
self.register()
|
||||
|
||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
parsed = parse_cmdlet_args(args, self)
|
||||
magnet_args = parsed.get("magnet", [])
|
||||
output_dir = Path(parsed.get("output") or Path.home() / "Downloads")
|
||||
wait_timeout = int(float(parsed.get("wait", 600)))
|
||||
background_mode = parsed.get("background", False)
|
||||
api_key = config.get("alldebrid_api_key")
|
||||
if not api_key:
|
||||
log("AllDebrid API key not configured", file=sys.stderr)
|
||||
return 1
|
||||
for magnet_url in magnet_args:
|
||||
if background_mode:
|
||||
self._start_background_worker(magnet_url, output_dir, config, api_key, wait_timeout)
|
||||
log(f"⧗ Torrent download queued in background: {magnet_url}")
|
||||
else:
|
||||
self._download_torrent_worker(str(uuid.uuid4()), magnet_url, output_dir, config, api_key, wait_timeout)
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _download_torrent_worker(
|
||||
worker_id: str,
|
||||
magnet_url: str,
|
||||
output_dir: Path,
|
||||
config: Dict[str, Any],
|
||||
api_key: str,
|
||||
wait_timeout: int = 600,
|
||||
worker_manager: Optional[Any] = None,
|
||||
) -> None:
|
||||
try:
|
||||
from helper.alldebrid import AllDebridClient
|
||||
client = AllDebridClient(api_key)
|
||||
log(f"[Worker {worker_id}] Submitting magnet to AllDebrid...")
|
||||
magnet_info = client.magnet_add(magnet_url)
|
||||
magnet_id = int(magnet_info.get('id', 0))
|
||||
if magnet_id <= 0:
|
||||
log(f"[Worker {worker_id}] Magnet add failed", file=sys.stderr)
|
||||
return
|
||||
log(f"[Worker {worker_id}] ✓ Magnet added (ID: {magnet_id})")
|
||||
# Poll for ready status (simplified)
|
||||
import time
|
||||
elapsed = 0
|
||||
while elapsed < wait_timeout:
|
||||
status = client.magnet_status(magnet_id)
|
||||
if status.get('ready'):
|
||||
break
|
||||
time.sleep(5)
|
||||
elapsed += 5
|
||||
if elapsed >= wait_timeout:
|
||||
log(f"[Worker {worker_id}] Timeout waiting for magnet", file=sys.stderr)
|
||||
return
|
||||
files_result = client.magnet_links([magnet_id])
|
||||
magnet_files = files_result.get(str(magnet_id), {})
|
||||
files_array = magnet_files.get('files', [])
|
||||
if not files_array:
|
||||
log(f"[Worker {worker_id}] No files found", file=sys.stderr)
|
||||
return
|
||||
for file_info in files_array:
|
||||
file_url = file_info.get('link')
|
||||
file_name = file_info.get('name')
|
||||
if file_url:
|
||||
Download_Torrent._download_file(file_url, output_dir / file_name)
|
||||
log(f"[Worker {worker_id}] ✓ Downloaded {file_name}")
|
||||
except Exception as e:
|
||||
log(f"[Worker {worker_id}] Torrent download failed: {e}", file=sys.stderr)
|
||||
|
||||
@staticmethod
|
||||
def _download_file(url: str, dest: Path) -> None:
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get(url, stream=True)
|
||||
with open(dest, 'wb') as f:
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
except Exception as e:
|
||||
log(f"File download failed: {e}", file=sys.stderr)
|
||||
|
||||
def _start_background_worker(self, magnet_url, output_dir, config, api_key, wait_timeout):
|
||||
worker_id = f"torrent_{uuid.uuid4().hex[:6]}"
|
||||
thread = threading.Thread(
|
||||
target=self._download_torrent_worker,
|
||||
args=(worker_id, magnet_url, output_dir, config, api_key, wait_timeout),
|
||||
daemon=False,
|
||||
name=f"TorrentWorker_{worker_id}",
|
||||
)
|
||||
thread.start()
|
||||
|
||||
CMDLET = Download_Torrent()
|
||||
1864
cmdlets/get_file.py
1864
cmdlets/get_file.py
File diff suppressed because it is too large
Load Diff
1708
cmdlets/get_file.py.backup
Normal file
1708
cmdlets/get_file.py.backup
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,337 +6,224 @@ import sys
|
||||
|
||||
from helper.logger import log
|
||||
from pathlib import Path
|
||||
import mimetypes
|
||||
import os
|
||||
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from helper.local_library import LocalLibraryDB
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash
|
||||
from config import get_local_storage_path
|
||||
from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field
|
||||
import pipeline as ctx
|
||||
from result_table import ResultTable
|
||||
|
||||
|
||||
def _extract_imported_ts(meta: Dict[str, Any]) -> Optional[int]:
|
||||
"""Extract an imported timestamp from Hydrus metadata if available."""
|
||||
if not isinstance(meta, dict):
|
||||
class Get_Metadata(Cmdlet):
|
||||
"""Class-based get-metadata cmdlet with self-registration."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize get-metadata cmdlet."""
|
||||
super().__init__(
|
||||
name="get-metadata",
|
||||
summary="Print metadata for files by hash and storage backend.",
|
||||
usage="get-metadata [-hash <sha256>] [-store <backend>]",
|
||||
alias=["meta"],
|
||||
arg=[
|
||||
SharedArgs.HASH,
|
||||
SharedArgs.STORE,
|
||||
],
|
||||
detail=[
|
||||
"- Retrieves metadata from storage backend using file hash as identifier.",
|
||||
"- Shows hash, MIME type, size, duration/pages, known url, and import timestamp.",
|
||||
"- Hash and store are taken from piped result or can be overridden with -hash/-store flags.",
|
||||
"- All metadata is retrieved from the storage backend's database (single source of truth).",
|
||||
],
|
||||
exec=self.run,
|
||||
)
|
||||
self.register()
|
||||
|
||||
@staticmethod
|
||||
def _extract_imported_ts(meta: Dict[str, Any]) -> Optional[int]:
|
||||
"""Extract an imported timestamp from metadata if available."""
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
|
||||
# Prefer explicit time_imported if present
|
||||
explicit = meta.get("time_imported")
|
||||
if isinstance(explicit, (int, float)):
|
||||
return int(explicit)
|
||||
|
||||
# Try parsing string timestamps
|
||||
if isinstance(explicit, str):
|
||||
try:
|
||||
import datetime as _dt
|
||||
return int(_dt.datetime.fromisoformat(explicit).timestamp())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
# Prefer explicit time_imported if present
|
||||
explicit = meta.get("time_imported")
|
||||
if isinstance(explicit, (int, float)):
|
||||
return int(explicit)
|
||||
|
||||
file_services = meta.get("file_services")
|
||||
if isinstance(file_services, dict):
|
||||
current = file_services.get("current")
|
||||
if isinstance(current, dict):
|
||||
numeric = [int(v) for v in current.values() if isinstance(v, (int, float))]
|
||||
if numeric:
|
||||
return min(numeric)
|
||||
return None
|
||||
|
||||
|
||||
def _format_imported(ts: Optional[int]) -> str:
|
||||
if not ts:
|
||||
return ""
|
||||
try:
|
||||
import datetime as _dt
|
||||
return _dt.datetime.utcfromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _build_table_row(title: str, origin: str, path: str, mime: str, size_bytes: Optional[int], dur_seconds: Optional[int], imported_ts: Optional[int], urls: list[str], hash_value: Optional[str], pages: Optional[int] = None) -> Dict[str, Any]:
|
||||
size_mb = None
|
||||
if isinstance(size_bytes, int):
|
||||
@staticmethod
|
||||
def _format_imported(ts: Optional[int]) -> str:
|
||||
"""Format timestamp as readable string."""
|
||||
if not ts:
|
||||
return ""
|
||||
try:
|
||||
size_mb = int(size_bytes / (1024 * 1024))
|
||||
import datetime as _dt
|
||||
return _dt.datetime.utcfromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
size_mb = None
|
||||
return ""
|
||||
|
||||
dur_int = int(dur_seconds) if isinstance(dur_seconds, (int, float)) else None
|
||||
pages_int = int(pages) if isinstance(pages, (int, float)) else None
|
||||
imported_label = _format_imported(imported_ts)
|
||||
@staticmethod
|
||||
def _build_table_row(title: str, origin: str, path: str, mime: str, size_bytes: Optional[int],
|
||||
dur_seconds: Optional[int], imported_ts: Optional[int], url: list[str],
|
||||
hash_value: Optional[str], pages: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Build a table row dict with metadata fields."""
|
||||
size_mb = None
|
||||
if isinstance(size_bytes, int):
|
||||
try:
|
||||
size_mb = int(size_bytes / (1024 * 1024))
|
||||
except Exception:
|
||||
size_mb = None
|
||||
|
||||
duration_label = "Duration(s)"
|
||||
duration_value = str(dur_int) if dur_int is not None else ""
|
||||
if mime and mime.lower().startswith("application/pdf"):
|
||||
duration_label = "Pages"
|
||||
duration_value = str(pages_int) if pages_int is not None else ""
|
||||
dur_int = int(dur_seconds) if isinstance(dur_seconds, (int, float)) else None
|
||||
pages_int = int(pages) if isinstance(pages, (int, float)) else None
|
||||
imported_label = Get_Metadata._format_imported(imported_ts)
|
||||
|
||||
columns = [
|
||||
("Title", title or ""),
|
||||
("Hash", hash_value or ""),
|
||||
("MIME", mime or ""),
|
||||
("Size(MB)", str(size_mb) if size_mb is not None else ""),
|
||||
(duration_label, duration_value),
|
||||
("Imported", imported_label),
|
||||
("Store", origin or ""),
|
||||
]
|
||||
duration_label = "Duration(s)"
|
||||
duration_value = str(dur_int) if dur_int is not None else ""
|
||||
if mime and mime.lower().startswith("application/pdf"):
|
||||
duration_label = "Pages"
|
||||
duration_value = str(pages_int) if pages_int is not None else ""
|
||||
|
||||
return {
|
||||
"title": title or path,
|
||||
"path": path,
|
||||
"origin": origin,
|
||||
"mime": mime,
|
||||
"size_bytes": size_bytes,
|
||||
"duration_seconds": dur_int,
|
||||
"pages": pages_int,
|
||||
"imported_ts": imported_ts,
|
||||
"imported": imported_label,
|
||||
"hash": hash_value,
|
||||
"known_urls": urls,
|
||||
"columns": columns,
|
||||
}
|
||||
columns = [
|
||||
("Title", title or ""),
|
||||
("Hash", hash_value or ""),
|
||||
("MIME", mime or ""),
|
||||
("Size(MB)", str(size_mb) if size_mb is not None else ""),
|
||||
(duration_label, duration_value),
|
||||
("Imported", imported_label),
|
||||
("Store", origin or ""),
|
||||
]
|
||||
|
||||
return {
|
||||
"title": title or path,
|
||||
"path": path,
|
||||
"origin": origin,
|
||||
"mime": mime,
|
||||
"size_bytes": size_bytes,
|
||||
"duration_seconds": dur_int,
|
||||
"pages": pages_int,
|
||||
"imported_ts": imported_ts,
|
||||
"imported": imported_label,
|
||||
"hash": hash_value,
|
||||
"url": url,
|
||||
"columns": columns,
|
||||
}
|
||||
|
||||
def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Help
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in _args):
|
||||
log(json.dumps(CMDLET.to_dict(), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Helper to get field from both dict and object
|
||||
def get_field(obj: Any, field: str, default: Any = None) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(field, default)
|
||||
@staticmethod
|
||||
def _add_table_body_row(table: ResultTable, row: Dict[str, Any]) -> None:
|
||||
"""Add a single row to the ResultTable using the prepared columns."""
|
||||
columns = row.get("columns") if isinstance(row, dict) else None
|
||||
lookup: Dict[str, Any] = {}
|
||||
if isinstance(columns, list):
|
||||
for col in columns:
|
||||
if isinstance(col, tuple) and len(col) == 2:
|
||||
label, value = col
|
||||
lookup[str(label)] = value
|
||||
|
||||
row_obj = table.add_row()
|
||||
row_obj.add_column("Hash", lookup.get("Hash", ""))
|
||||
row_obj.add_column("MIME", lookup.get("MIME", ""))
|
||||
row_obj.add_column("Size(MB)", lookup.get("Size(MB)", ""))
|
||||
if "Duration(s)" in lookup:
|
||||
row_obj.add_column("Duration(s)", lookup.get("Duration(s)", ""))
|
||||
elif "Pages" in lookup:
|
||||
row_obj.add_column("Pages", lookup.get("Pages", ""))
|
||||
else:
|
||||
return getattr(obj, field, default)
|
||||
|
||||
# Parse -hash override
|
||||
override_hash: str | None = None
|
||||
args_list = list(_args)
|
||||
i = 0
|
||||
while i < len(args_list):
|
||||
a = args_list[i]
|
||||
low = str(a).lower()
|
||||
if low in {"-hash", "--hash", "hash"} and i + 1 < len(args_list):
|
||||
override_hash = str(args_list[i + 1]).strip()
|
||||
break
|
||||
i += 1
|
||||
|
||||
# Try to determine if this is a local file or Hydrus file
|
||||
local_path = get_field(result, "target", None) or get_field(result, "path", None)
|
||||
is_local = False
|
||||
if local_path and isinstance(local_path, str) and not local_path.startswith(("http://", "https://")):
|
||||
is_local = True
|
||||
|
||||
# LOCAL FILE PATH
|
||||
if is_local and local_path:
|
||||
row_obj.add_column("Duration(s)", "")
|
||||
|
||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Main execution entry point."""
|
||||
# Parse arguments
|
||||
parsed = parse_cmdlet_args(args, self)
|
||||
|
||||
# Get hash and store from parsed args or result
|
||||
file_hash = parsed.get("hash") or get_field(result, "hash") or get_field(result, "file_hash") or get_field(result, "hash_hex")
|
||||
storage_source = parsed.get("store") or get_field(result, "store") or get_field(result, "storage") or get_field(result, "origin")
|
||||
|
||||
if not file_hash:
|
||||
log("No hash available - use -hash to specify", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not storage_source:
|
||||
log("No storage backend specified - use -store to specify", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Use storage backend to get metadata
|
||||
try:
|
||||
file_path = Path(str(local_path))
|
||||
if file_path.exists() and file_path.is_file():
|
||||
# Get the hash from result or compute it
|
||||
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(get_field(result, "hash_hex", None))
|
||||
|
||||
# If no hash, compute SHA256 of the file
|
||||
if not hash_hex:
|
||||
try:
|
||||
import hashlib
|
||||
with open(file_path, 'rb') as f:
|
||||
hash_hex = hashlib.sha256(f.read()).hexdigest()
|
||||
except Exception:
|
||||
hash_hex = None
|
||||
|
||||
# Get MIME type
|
||||
mime_type, _ = mimetypes.guess_type(str(file_path))
|
||||
if not mime_type:
|
||||
mime_type = "unknown"
|
||||
|
||||
# Pull metadata from local DB if available (for imported timestamp, duration, etc.)
|
||||
db_metadata = None
|
||||
library_root = get_local_storage_path(config)
|
||||
if library_root:
|
||||
try:
|
||||
with LocalLibraryDB(library_root) as db:
|
||||
db_metadata = db.get_metadata(file_path) or None
|
||||
except Exception:
|
||||
db_metadata = None
|
||||
|
||||
# Get file size (prefer DB size if present)
|
||||
file_size = None
|
||||
if isinstance(db_metadata, dict) and isinstance(db_metadata.get("size"), int):
|
||||
file_size = db_metadata.get("size")
|
||||
else:
|
||||
try:
|
||||
file_size = file_path.stat().st_size
|
||||
except Exception:
|
||||
file_size = None
|
||||
|
||||
# Duration/pages
|
||||
duration_seconds = None
|
||||
pages = None
|
||||
if isinstance(db_metadata, dict):
|
||||
if isinstance(db_metadata.get("duration"), (int, float)):
|
||||
duration_seconds = float(db_metadata.get("duration"))
|
||||
if isinstance(db_metadata.get("pages"), (int, float)):
|
||||
pages = int(db_metadata.get("pages"))
|
||||
|
||||
if duration_seconds is None and mime_type and mime_type.startswith("video"):
|
||||
try:
|
||||
import subprocess
|
||||
result_proc = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(file_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result_proc.returncode == 0 and result_proc.stdout.strip():
|
||||
duration_seconds = float(result_proc.stdout.strip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Known URLs from sidecar or result
|
||||
urls = []
|
||||
sidecar_path = Path(str(file_path) + '.tags')
|
||||
if sidecar_path.exists():
|
||||
try:
|
||||
with open(sidecar_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line.startswith('known_url:'):
|
||||
url_value = line.replace('known_url:', '', 1).strip()
|
||||
if url_value:
|
||||
urls.append(url_value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not urls:
|
||||
urls_from_result = get_field(result, "known_urls", None) or get_field(result, "urls", None)
|
||||
if isinstance(urls_from_result, list):
|
||||
urls.extend([str(u).strip() for u in urls_from_result if u])
|
||||
|
||||
imported_ts = None
|
||||
if isinstance(db_metadata, dict):
|
||||
ts = db_metadata.get("time_imported") or db_metadata.get("time_added")
|
||||
if isinstance(ts, (int, float)):
|
||||
imported_ts = int(ts)
|
||||
elif isinstance(ts, str):
|
||||
try:
|
||||
import datetime as _dt
|
||||
imported_ts = int(_dt.datetime.fromisoformat(ts).timestamp())
|
||||
except Exception:
|
||||
imported_ts = None
|
||||
|
||||
row = _build_table_row(
|
||||
title=file_path.name,
|
||||
origin="local",
|
||||
path=str(file_path),
|
||||
mime=mime_type or "",
|
||||
size_bytes=int(file_size) if isinstance(file_size, int) else None,
|
||||
dur_seconds=duration_seconds,
|
||||
imported_ts=imported_ts,
|
||||
urls=urls,
|
||||
hash_value=hash_hex,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
table_title = file_path.name
|
||||
table = ResultTable(table_title)
|
||||
table.set_source_command("get-metadata", list(_args))
|
||||
table.add_result(row)
|
||||
ctx.set_last_result_table_overlay(table, [row], row)
|
||||
ctx.emit(row)
|
||||
return 0
|
||||
except Exception:
|
||||
# Fall through to Hydrus if local file handling fails
|
||||
pass
|
||||
|
||||
# HYDRUS PATH
|
||||
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(get_field(result, "hash_hex", None))
|
||||
if not hash_hex:
|
||||
log("Selected result does not include a Hydrus hash or local path", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
client = hydrus_wrapper.get_client(config)
|
||||
except Exception as exc:
|
||||
log(f"Hydrus client unavailable: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if client is None:
|
||||
log("Hydrus client unavailable", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
payload = client.fetch_file_metadata(
|
||||
hashes=[hash_hex],
|
||||
include_service_keys_to_tags=False,
|
||||
include_file_urls=True,
|
||||
include_duration=True,
|
||||
include_size=True,
|
||||
include_mime=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
log(f"Hydrus metadata fetch failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
items = payload.get("metadata") if isinstance(payload, dict) else None
|
||||
if not isinstance(items, list) or not items:
|
||||
log("No metadata found.")
|
||||
return 0
|
||||
|
||||
meta = items[0] if isinstance(items[0], dict) else None
|
||||
if not isinstance(meta, dict):
|
||||
log("No metadata found.")
|
||||
return 0
|
||||
|
||||
mime = meta.get("mime")
|
||||
size = meta.get("size") or meta.get("file_size")
|
||||
duration_value = meta.get("duration")
|
||||
inner = meta.get("metadata") if isinstance(meta.get("metadata"), dict) else None
|
||||
if duration_value is None and isinstance(inner, dict):
|
||||
duration_value = inner.get("duration")
|
||||
|
||||
imported_ts = _extract_imported_ts(meta)
|
||||
|
||||
try:
|
||||
from .search_file import _hydrus_duration_seconds as _dur_secs
|
||||
except Exception:
|
||||
_dur_secs = lambda x: x
|
||||
|
||||
dur_seconds = _dur_secs(duration_value)
|
||||
urls = meta.get("known_urls") or meta.get("urls")
|
||||
urls = [str(u).strip() for u in urls] if isinstance(urls, list) else []
|
||||
|
||||
row = _build_table_row(
|
||||
title=hash_hex,
|
||||
origin="hydrus",
|
||||
path=f"hydrus://file/{hash_hex}",
|
||||
mime=mime or "",
|
||||
size_bytes=int(size) if isinstance(size, int) else None,
|
||||
dur_seconds=int(dur_seconds) if isinstance(dur_seconds, (int, float)) else None,
|
||||
imported_ts=imported_ts,
|
||||
urls=urls,
|
||||
hash_value=hash_hex,
|
||||
pages=None,
|
||||
)
|
||||
|
||||
table = ResultTable(hash_hex or "Metadata")
|
||||
table.set_source_command("get-metadata", list(_args))
|
||||
table.add_result(row)
|
||||
ctx.set_last_result_table_overlay(table, [row], row)
|
||||
ctx.emit(row)
|
||||
|
||||
return 0
|
||||
from helper.store import FileStorage
|
||||
storage = FileStorage(config)
|
||||
backend = storage[storage_source]
|
||||
|
||||
# Get metadata from backend
|
||||
metadata = backend.get_metadata(file_hash)
|
||||
|
||||
if not metadata:
|
||||
log(f"No metadata found for hash {file_hash[:8]}... in {storage_source}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Extract title from tags if available
|
||||
title = get_field(result, "title") or file_hash[:16]
|
||||
if not get_field(result, "title"):
|
||||
# Try to get title from tags
|
||||
try:
|
||||
tags, _ = backend.get_tag(file_hash)
|
||||
for tag in tags:
|
||||
if tag.lower().startswith("title:"):
|
||||
title = tag.split(":", 1)[1]
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extract metadata fields
|
||||
mime_type = metadata.get("mime") or metadata.get("ext", "")
|
||||
file_size = metadata.get("size")
|
||||
duration_seconds = metadata.get("duration")
|
||||
pages = metadata.get("pages")
|
||||
url = metadata.get("url") or []
|
||||
imported_ts = self._extract_imported_ts(metadata)
|
||||
|
||||
# Normalize url
|
||||
if isinstance(url, str):
|
||||
try:
|
||||
url = json.loads(url)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
url = []
|
||||
if not isinstance(url, list):
|
||||
url = []
|
||||
|
||||
# Build display row
|
||||
row = self._build_table_row(
|
||||
title=title,
|
||||
origin=storage_source,
|
||||
path=metadata.get("file_path", ""),
|
||||
mime=mime_type,
|
||||
size_bytes=file_size,
|
||||
dur_seconds=duration_seconds,
|
||||
imported_ts=imported_ts,
|
||||
url=url,
|
||||
hash_value=file_hash,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
table_title = title
|
||||
table = ResultTable(table_title).init_command("get-metadata", list(args))
|
||||
self._add_table_body_row(table, row)
|
||||
ctx.set_last_result_table_overlay(table, [row], row)
|
||||
ctx.emit(row)
|
||||
return 0
|
||||
|
||||
except KeyError:
|
||||
log(f"Storage backend '{storage_source}' not found", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as exc:
|
||||
log(f"Failed to get metadata: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="get-metadata",
|
||||
summary="Print metadata for local or Hydrus files (hash, mime, duration, size, URLs).",
|
||||
usage="get-metadata [-hash <sha256>]",
|
||||
aliases=["meta"],
|
||||
args=[
|
||||
CmdletArg("hash", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."),
|
||||
],
|
||||
details=[
|
||||
"- For local files: Shows path, hash (computed if needed), MIME type, size, duration, and known URLs from sidecar.",
|
||||
"- For Hydrus files: Shows path (hydrus://), hash, MIME, duration, size, and known URLs.",
|
||||
"- Automatically detects local vs Hydrus files.",
|
||||
"- Local file hashes are computed via SHA256 if not already available.",
|
||||
],
|
||||
)
|
||||
CMDLET = Get_Metadata()
|
||||
|
||||
@@ -7,17 +7,17 @@ from . import register
|
||||
import models
|
||||
import pipeline as ctx
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash
|
||||
from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, get_hash_for_operation, fetch_hydrus_metadata, get_field, should_show_help
|
||||
from helper.logger import log
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="get-note",
|
||||
summary="List notes on a Hydrus file.",
|
||||
usage="get-note [-hash <sha256>]",
|
||||
args=[
|
||||
CmdletArg("-hash", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."),
|
||||
arg=[
|
||||
SharedArgs.HASH,
|
||||
],
|
||||
details=[
|
||||
detail=[
|
||||
"- Prints notes by service and note name.",
|
||||
],
|
||||
)
|
||||
@@ -25,45 +25,24 @@ CMDLET = Cmdlet(
|
||||
|
||||
@register(["get-note", "get-notes", "get_note"]) # aliases
|
||||
def get_notes(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Helper to get field from both dict and object
|
||||
def get_field(obj: Any, field: str, default: Any = None) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(field, default)
|
||||
else:
|
||||
return getattr(obj, field, default)
|
||||
|
||||
# Help
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
if should_show_help(args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
from ._shared import parse_cmdlet_args
|
||||
from ._shared import parse_cmdlet_args, get_hash_for_operation, fetch_hydrus_metadata
|
||||
parsed = parse_cmdlet_args(args, CMDLET)
|
||||
override_hash = parsed.get("hash")
|
||||
|
||||
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(get_field(result, "hash_hex", None))
|
||||
hash_hex = get_hash_for_operation(override_hash, result)
|
||||
if not hash_hex:
|
||||
log("Selected result does not include a Hydrus hash")
|
||||
return 1
|
||||
try:
|
||||
client = hydrus_wrapper.get_client(config)
|
||||
except Exception as exc:
|
||||
log(f"Hydrus client unavailable: {exc}")
|
||||
return 1
|
||||
|
||||
if client is None:
|
||||
log("Hydrus client unavailable")
|
||||
return 1
|
||||
try:
|
||||
payload = client.fetch_file_metadata(hashes=[hash_hex], include_service_keys_to_tags=False, include_notes=True)
|
||||
except Exception as exc:
|
||||
log(f"Hydrus metadata fetch failed: {exc}")
|
||||
return 1
|
||||
items = payload.get("metadata") if isinstance(payload, dict) else None
|
||||
meta = items[0] if (isinstance(items, list) and items and isinstance(items[0], dict)) else None
|
||||
meta, error_code = fetch_hydrus_metadata(config, hash_hex, include_service_keys_to_tags=False, include_notes=True)
|
||||
if error_code != 0:
|
||||
return error_code
|
||||
|
||||
notes = {}
|
||||
if isinstance(meta, dict):
|
||||
# Hydrus returns service_keys_to_tags; for notes we expect 'service_names_to_notes' in modern API
|
||||
|
||||
@@ -7,12 +7,11 @@ from pathlib import Path
|
||||
|
||||
from helper.logger import log
|
||||
|
||||
from . import register
|
||||
import models
|
||||
import pipeline as ctx
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash, fmt_bytes
|
||||
from helper.local_library import LocalLibraryDB
|
||||
from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, fmt_bytes, get_hash_for_operation, fetch_hydrus_metadata, should_show_help
|
||||
from helper.folder_store import FolderDB
|
||||
from config import get_local_storage_path
|
||||
from result_table import ResultTable
|
||||
|
||||
@@ -20,23 +19,22 @@ CMDLET = Cmdlet(
|
||||
name="get-relationship",
|
||||
summary="Print relationships for the selected file (Hydrus or Local).",
|
||||
usage="get-relationship [-hash <sha256>]",
|
||||
args=[
|
||||
CmdletArg("-hash", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."),
|
||||
alias=[
|
||||
"get-rel",
|
||||
],
|
||||
details=[
|
||||
arg=[
|
||||
SharedArgs.HASH,
|
||||
],
|
||||
detail=[
|
||||
"- Lists relationship data as returned by Hydrus or Local DB.",
|
||||
],
|
||||
)
|
||||
|
||||
@register(["get-rel", "get-relationship", "get-relationships", "get-file-relationships"]) # aliases
|
||||
def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Help
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in _args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
if should_show_help(_args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
# Parse -hash override
|
||||
override_hash: str | None = None
|
||||
@@ -91,8 +89,9 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
storage_path = get_local_storage_path(config)
|
||||
print(f"[DEBUG] Storage path: {storage_path}", file=sys.stderr)
|
||||
if storage_path:
|
||||
with LocalLibraryDB(storage_path) as db:
|
||||
metadata = db.get_metadata(path_obj)
|
||||
with FolderDB(storage_path) as db:
|
||||
file_hash = db.get_file_hash(path_obj)
|
||||
metadata = db.get_metadata(file_hash) if file_hash else None
|
||||
print(f"[DEBUG] Metadata found: {metadata is not None}", file=sys.stderr)
|
||||
if metadata and metadata.get("relationships"):
|
||||
local_db_checked = True
|
||||
@@ -106,14 +105,14 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# h is now a file hash (not a path)
|
||||
print(f"[DEBUG] Processing relationship hash: h={h}", file=sys.stderr)
|
||||
# Resolve hash to file path
|
||||
resolved_path = db.search_by_hash(h)
|
||||
resolved_path = db.search_hash(h)
|
||||
title = h[:16] + "..."
|
||||
path = None
|
||||
if resolved_path and resolved_path.exists():
|
||||
path = str(resolved_path)
|
||||
# Try to get title from tags
|
||||
try:
|
||||
tags = db.get_tags(resolved_path)
|
||||
tags = db.get_tags(h)
|
||||
found_title = False
|
||||
for t in tags:
|
||||
if t.lower().startswith('title:'):
|
||||
@@ -154,11 +153,13 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if not existing_parent:
|
||||
parent_title = parent_path_obj.stem
|
||||
try:
|
||||
parent_tags = db.get_tags(parent_path_obj)
|
||||
for t in parent_tags:
|
||||
if t.lower().startswith('title:'):
|
||||
parent_title = t[6:].strip()
|
||||
break
|
||||
parent_hash = db.get_file_hash(parent_path_obj)
|
||||
if parent_hash:
|
||||
parent_tags = db.get_tags(parent_hash)
|
||||
for t in parent_tags:
|
||||
if t.lower().startswith('title:'):
|
||||
parent_title = t[6:].strip()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -176,7 +177,8 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
existing_parent['type'] = "king"
|
||||
|
||||
# 1. Check forward relationships from parent (siblings)
|
||||
parent_metadata = db.get_metadata(parent_path_obj)
|
||||
parent_hash = db.get_file_hash(parent_path_obj)
|
||||
parent_metadata = db.get_metadata(parent_hash) if parent_hash else None
|
||||
print(f"[DEBUG] 📖 Parent metadata: {parent_metadata is not None}", file=sys.stderr)
|
||||
if parent_metadata:
|
||||
print(f"[DEBUG] Parent metadata keys: {parent_metadata.keys()}", file=sys.stderr)
|
||||
@@ -189,7 +191,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if child_hashes:
|
||||
for child_h in child_hashes:
|
||||
# child_h is now a HASH, not a path - resolve it
|
||||
child_path_obj = db.search_by_hash(child_h)
|
||||
child_path_obj = db.search_hash(child_h)
|
||||
print(f"[DEBUG] Resolved hash {child_h[:16]}... to: {child_path_obj}", file=sys.stderr)
|
||||
|
||||
if not child_path_obj:
|
||||
@@ -205,11 +207,13 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Now child_path_obj is a Path, so we can get tags
|
||||
child_title = child_path_obj.stem
|
||||
try:
|
||||
child_tags = db.get_tags(child_path_obj)
|
||||
for t in child_tags:
|
||||
if t.lower().startswith('title:'):
|
||||
child_title = t[6:].strip()
|
||||
break
|
||||
child_hash = db.get_file_hash(child_path_obj)
|
||||
if child_hash:
|
||||
child_tags = db.get_tags(child_hash)
|
||||
for t in child_tags:
|
||||
if t.lower().startswith('title:'):
|
||||
child_title = t[6:].strip()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -241,11 +245,13 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
child_path_obj = Path(child_path)
|
||||
child_title = child_path_obj.stem
|
||||
try:
|
||||
child_tags = db.get_tags(child_path_obj)
|
||||
for t in child_tags:
|
||||
if t.lower().startswith('title:'):
|
||||
child_title = t[6:].strip()
|
||||
break
|
||||
child_hash = db.get_file_hash(child_path_obj)
|
||||
if child_hash:
|
||||
child_tags = db.get_tags(child_hash)
|
||||
for t in child_tags:
|
||||
if t.lower().startswith('title:'):
|
||||
child_title = t[6:].strip()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -304,11 +310,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# But if the file is also in Hydrus, we might want those too.
|
||||
# Let's try Hydrus if we have a hash.
|
||||
|
||||
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(getattr(result, "hash_hex", None))
|
||||
if not hash_hex:
|
||||
# Try to get hash from dict
|
||||
if isinstance(result, dict):
|
||||
hash_hex = normalize_hash(result.get("hash") or result.get("file_hash"))
|
||||
hash_hex = get_hash_for_operation(override_hash, result)
|
||||
|
||||
if hash_hex and not local_db_checked:
|
||||
try:
|
||||
@@ -362,7 +364,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
return 0
|
||||
|
||||
# Display results
|
||||
table = ResultTable(f"Relationships: {source_title}")
|
||||
table = ResultTable(f"Relationships: {source_title}").init_command("get-relationship", [])
|
||||
|
||||
# Sort by type then title
|
||||
# Custom sort order: King first, then Derivative, then others
|
||||
|
||||
@@ -20,8 +20,8 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
import pipeline as ctx
|
||||
from helper import hydrus
|
||||
from helper.local_library import read_sidecar, write_sidecar, find_sidecar, LocalLibraryDB
|
||||
from ._shared import normalize_hash, looks_like_hash, Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args
|
||||
from helper.folder_store import read_sidecar, write_sidecar, find_sidecar, FolderDB
|
||||
from ._shared import normalize_hash, looks_like_hash, Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field
|
||||
from config import get_local_storage_path
|
||||
|
||||
|
||||
@@ -71,33 +71,6 @@ class TagItem:
|
||||
}
|
||||
|
||||
|
||||
def _extract_my_tags_from_hydrus_meta(meta: Dict[str, Any], service_key: Optional[str], service_name: str) -> List[str]:
|
||||
"""Extract current tags from Hydrus metadata dict.
|
||||
|
||||
Prefers display_tags (includes siblings/parents, excludes deleted).
|
||||
Falls back to storage_tags status '0' (current).
|
||||
"""
|
||||
tags_payload = meta.get("tags")
|
||||
if not isinstance(tags_payload, dict):
|
||||
return []
|
||||
svc_data = None
|
||||
if service_key:
|
||||
svc_data = tags_payload.get(service_key)
|
||||
if not isinstance(svc_data, dict):
|
||||
return []
|
||||
# Prefer display_tags (Hydrus computes siblings/parents)
|
||||
display = svc_data.get("display_tags")
|
||||
if isinstance(display, list) and display:
|
||||
return [str(t) for t in display if isinstance(t, (str, bytes)) and str(t).strip()]
|
||||
# Fallback to storage_tags status '0' (current)
|
||||
storage = svc_data.get("storage_tags")
|
||||
if isinstance(storage, dict):
|
||||
current_list = storage.get("0") or storage.get(0)
|
||||
if isinstance(current_list, list):
|
||||
return [str(t) for t in current_list if isinstance(t, (str, bytes)) and str(t).strip()]
|
||||
return []
|
||||
|
||||
|
||||
def _emit_tags_as_table(
|
||||
tags_list: List[str],
|
||||
hash_hex: Optional[str],
|
||||
@@ -316,12 +289,12 @@ def _read_sidecar_fallback(p: Path) -> tuple[Optional[str], List[str], List[str]
|
||||
|
||||
Format:
|
||||
- Lines with "hash:" prefix: file hash
|
||||
- Lines with "known_url:" or "url:" prefix: URLs
|
||||
- Lines with "url:" or "url:" prefix: url
|
||||
- Lines with "relationship:" prefix: ignored (internal relationships)
|
||||
- Lines with "key:", "namespace:value" format: treated as namespace tags
|
||||
- Plain lines without colons: freeform tags
|
||||
|
||||
Excluded namespaces (treated as metadata, not tags): hash, known_url, url, relationship
|
||||
Excluded namespaces (treated as metadata, not tags): hash, url, url, relationship
|
||||
"""
|
||||
try:
|
||||
raw = p.read_text(encoding="utf-8", errors="ignore")
|
||||
@@ -332,7 +305,7 @@ def _read_sidecar_fallback(p: Path) -> tuple[Optional[str], List[str], List[str]
|
||||
h: Optional[str] = None
|
||||
|
||||
# Namespaces to exclude from tags
|
||||
excluded_namespaces = {"hash", "known_url", "url", "relationship"}
|
||||
excluded_namespaces = {"hash", "url", "url", "relationship"}
|
||||
|
||||
for line in raw.splitlines():
|
||||
s = line.strip()
|
||||
@@ -344,7 +317,7 @@ def _read_sidecar_fallback(p: Path) -> tuple[Optional[str], List[str], List[str]
|
||||
if low.startswith("hash:"):
|
||||
h = s.split(":", 1)[1].strip() if ":" in s else h
|
||||
# Check if this is a URL line
|
||||
elif low.startswith("known_url:") or low.startswith("url:"):
|
||||
elif low.startswith("url:") or low.startswith("url:"):
|
||||
val = s.split(":", 1)[1].strip() if ":" in s else ""
|
||||
if val:
|
||||
u.append(val)
|
||||
@@ -361,12 +334,12 @@ def _read_sidecar_fallback(p: Path) -> tuple[Optional[str], List[str], List[str]
|
||||
return h, t, u
|
||||
|
||||
|
||||
def _write_sidecar(p: Path, media: Path, tag_list: List[str], known_urls: List[str], hash_in_sidecar: Optional[str]) -> Path:
|
||||
def _write_sidecar(p: Path, media: Path, tag_list: List[str], url: List[str], hash_in_sidecar: Optional[str]) -> Path:
|
||||
"""Write tags to sidecar file and handle title-based renaming.
|
||||
|
||||
Returns the new media path if renamed, otherwise returns the original media path.
|
||||
"""
|
||||
success = write_sidecar(media, tag_list, known_urls, hash_in_sidecar)
|
||||
success = write_sidecar(media, tag_list, url, hash_in_sidecar)
|
||||
if success:
|
||||
_apply_result_updates_from_tags(None, tag_list)
|
||||
# Check if we should rename the file based on title tag
|
||||
@@ -381,8 +354,8 @@ def _write_sidecar(p: Path, media: Path, tag_list: List[str], known_urls: List[s
|
||||
if hash_in_sidecar:
|
||||
lines.append(f"hash:{hash_in_sidecar}")
|
||||
lines.extend(ordered)
|
||||
for u in known_urls:
|
||||
lines.append(f"known_url:{u}")
|
||||
for u in url:
|
||||
lines.append(f"url:{u}")
|
||||
try:
|
||||
p.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
# Check if we should rename the file based on title tag
|
||||
@@ -414,16 +387,16 @@ def _emit_tag_payload(source: str, tags_list: List[str], *, hash_value: Optional
|
||||
label = None
|
||||
if store_label:
|
||||
label = store_label
|
||||
elif ctx._PIPE_ACTIVE:
|
||||
elif ctx.get_stage_context() is not None:
|
||||
label = "tags"
|
||||
if label:
|
||||
ctx.store_value(label, payload)
|
||||
if ctx._PIPE_ACTIVE and label.lower() != "tags":
|
||||
if ctx.get_stage_context() is not None and label.lower() != "tags":
|
||||
ctx.store_value("tags", payload)
|
||||
|
||||
# Emit individual TagItem objects so they can be selected by bare index
|
||||
# When in pipeline, emit individual TagItem objects
|
||||
if ctx._PIPE_ACTIVE:
|
||||
if ctx.get_stage_context() is not None:
|
||||
for idx, tag_name in enumerate(tags_list, start=1):
|
||||
tag_item = TagItem(
|
||||
tag_name=tag_name,
|
||||
@@ -1113,7 +1086,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
# Try local sidecar if no tags present on result
|
||||
if not identifier_tags:
|
||||
file_path = get_field(result, "target", None) or get_field(result, "path", None) or get_field(result, "file_path", None) or get_field(result, "filename", None)
|
||||
file_path = get_field(result, "target", None) or get_field(result, "path", None) or get_field(result, "filename", None)
|
||||
if isinstance(file_path, str) and file_path and not file_path.lower().startswith(("http://", "https://")):
|
||||
try:
|
||||
media_path = Path(str(file_path))
|
||||
@@ -1226,103 +1199,35 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
emit_mode = emit_requested or bool(store_key)
|
||||
store_label = (store_key.strip() if store_key and store_key.strip() else None)
|
||||
|
||||
# Check Hydrus availability
|
||||
hydrus_available, _ = hydrus.is_available(config)
|
||||
# Get hash and store from result
|
||||
file_hash = hash_hex
|
||||
storage_source = get_field(result, "store") or get_field(result, "storage") or get_field(result, "origin")
|
||||
|
||||
# Try to find path in result object
|
||||
local_path = get_field(result, "target", None) or get_field(result, "path", None) or get_field(result, "file_path", None)
|
||||
if not file_hash:
|
||||
log("No hash available in result", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Determine if local file
|
||||
is_local_file = False
|
||||
media: Optional[Path] = None
|
||||
if local_path and isinstance(local_path, str) and not local_path.startswith(("http://", "https://")):
|
||||
is_local_file = True
|
||||
try:
|
||||
media = Path(str(local_path))
|
||||
except Exception:
|
||||
media = None
|
||||
if not storage_source:
|
||||
log("No storage backend specified in result", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Try Hydrus first (always prioritize if available and has hash)
|
||||
use_hydrus = False
|
||||
hydrus_meta = None # Cache the metadata from first fetch
|
||||
client = None
|
||||
if hash_hex and hydrus_available:
|
||||
try:
|
||||
client = hydrus.get_client(config)
|
||||
payload = client.fetch_file_metadata(hashes=[str(hash_hex)], include_service_keys_to_tags=True, include_file_urls=False)
|
||||
items = payload.get("metadata") if isinstance(payload, dict) else None
|
||||
if isinstance(items, list) and items:
|
||||
meta = items[0] if isinstance(items[0], dict) else None
|
||||
# Only accept file if it has a valid file_id (not None)
|
||||
if isinstance(meta, dict) and meta.get("file_id") is not None:
|
||||
use_hydrus = True
|
||||
hydrus_meta = meta # Cache for tag extraction
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get tags - try Hydrus first, fallback to sidecar
|
||||
current = []
|
||||
service_name = ""
|
||||
service_key = None
|
||||
source = "unknown"
|
||||
|
||||
if use_hydrus and hash_hex and hydrus_meta:
|
||||
try:
|
||||
# Use cached metadata from above, don't fetch again
|
||||
service_name = hydrus.get_tag_service_name(config)
|
||||
if client is None:
|
||||
client = hydrus.get_client(config)
|
||||
service_key = hydrus.get_tag_service_key(client, service_name)
|
||||
current = _extract_my_tags_from_hydrus_meta(hydrus_meta, service_key, service_name)
|
||||
source = "hydrus"
|
||||
except Exception as exc:
|
||||
log(f"Warning: Failed to extract tags from Hydrus: {exc}", file=sys.stderr)
|
||||
|
||||
# Fallback to local sidecar or local DB if no tags
|
||||
if not current and is_local_file and media and media.exists():
|
||||
try:
|
||||
# First try local library DB
|
||||
library_root = get_local_storage_path(config)
|
||||
if library_root:
|
||||
try:
|
||||
with LocalLibraryDB(library_root) as db:
|
||||
db_tags = db.get_tags(media)
|
||||
if db_tags:
|
||||
current = db_tags
|
||||
source = "local_db"
|
||||
except Exception as exc:
|
||||
log(f"[get_tag] DB lookup failed, trying sidecar: {exc}", file=sys.stderr)
|
||||
|
||||
# Fall back to sidecar if DB didn't have tags
|
||||
if not current:
|
||||
sidecar_path = find_sidecar(media)
|
||||
if sidecar_path and sidecar_path.exists():
|
||||
try:
|
||||
_, current, _ = read_sidecar(sidecar_path)
|
||||
except Exception:
|
||||
_, current, _ = _read_sidecar_fallback(sidecar_path)
|
||||
if current:
|
||||
source = "sidecar"
|
||||
except Exception as exc:
|
||||
log(f"Warning: Failed to load tags from local storage: {exc}", file=sys.stderr)
|
||||
|
||||
# Fallback to tags in the result object if Hydrus/local lookup returned nothing
|
||||
if not current:
|
||||
# Check if result has 'tags' attribute (PipeObject)
|
||||
if hasattr(result, 'tags') and getattr(result, 'tags', None):
|
||||
current = getattr(result, 'tags')
|
||||
source = "pipeline_result"
|
||||
# Check if result is a dict with 'tags' key
|
||||
elif isinstance(result, dict) and 'tags' in result:
|
||||
tags_val = result['tags']
|
||||
if isinstance(tags_val, list):
|
||||
current = tags_val
|
||||
source = "pipeline_result"
|
||||
source = "pipeline_result"
|
||||
|
||||
# Error if no tags found
|
||||
if not current:
|
||||
log("No tags found", file=sys.stderr)
|
||||
# Get tags using storage backend
|
||||
try:
|
||||
from helper.store import FileStorage
|
||||
storage = FileStorage(config)
|
||||
backend = storage[storage_source]
|
||||
current, source = backend.get_tag(file_hash, config=config)
|
||||
|
||||
if not current:
|
||||
log("No tags found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
service_name = ""
|
||||
except KeyError:
|
||||
log(f"Storage backend '{storage_source}' not found", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as exc:
|
||||
log(f"Failed to get tags: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Always output to ResultTable (pipeline mode only)
|
||||
@@ -1383,33 +1288,106 @@ except Exception:
|
||||
_SCRAPE_CHOICES = ["itunes", "openlibrary", "googlebooks", "google", "musicbrainz"]
|
||||
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="get-tag",
|
||||
summary="Get tags from Hydrus or local sidecar metadata",
|
||||
usage="get-tag [-hash <sha256>] [--store <key>] [--emit] [-scrape <url|provider>]",
|
||||
aliases=["tags"],
|
||||
args=[
|
||||
SharedArgs.HASH,
|
||||
CmdletArg(
|
||||
name="-store",
|
||||
type="string",
|
||||
description="Store result to this key for pipeline",
|
||||
alias="store"
|
||||
),
|
||||
CmdletArg(
|
||||
name="-emit",
|
||||
type="flag",
|
||||
description="Emit result without interactive prompt (quiet mode)",
|
||||
alias="emit-only"
|
||||
),
|
||||
CmdletArg(
|
||||
name="-scrape",
|
||||
type="string",
|
||||
description="Scrape metadata from URL or provider name (returns tags as JSON or table)",
|
||||
required=False,
|
||||
choices=_SCRAPE_CHOICES,
|
||||
)
|
||||
]
|
||||
)
|
||||
class Get_Tag(Cmdlet):
|
||||
"""Class-based get-tag cmdlet with self-registration."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize get-tag cmdlet."""
|
||||
super().__init__(
|
||||
name="get-tag",
|
||||
summary="Get tags from Hydrus or local sidecar metadata",
|
||||
usage="get-tag [-hash <sha256>] [--store <key>] [--emit] [-scrape <url|provider>]",
|
||||
alias=["tags"],
|
||||
arg=[
|
||||
SharedArgs.HASH,
|
||||
CmdletArg(
|
||||
name="-store",
|
||||
type="string",
|
||||
description="Store result to this key for pipeline",
|
||||
alias="store"
|
||||
),
|
||||
CmdletArg(
|
||||
name="-emit",
|
||||
type="flag",
|
||||
description="Emit result without interactive prompt (quiet mode)",
|
||||
alias="emit-only"
|
||||
),
|
||||
CmdletArg(
|
||||
name="-scrape",
|
||||
type="string",
|
||||
description="Scrape metadata from URL or provider name (returns tags as JSON or table)",
|
||||
required=False,
|
||||
choices=_SCRAPE_CHOICES,
|
||||
)
|
||||
],
|
||||
detail=[
|
||||
"- Retrieves tags for a file from:",
|
||||
" Hydrus: Using file hash if available",
|
||||
" Local: From sidecar files or local library database",
|
||||
"- Options:",
|
||||
" -hash: Override hash to look up in Hydrus",
|
||||
" -store: Store result to key for downstream pipeline",
|
||||
" -emit: Quiet mode (no interactive selection)",
|
||||
" -scrape: Scrape metadata from URL or metadata provider",
|
||||
],
|
||||
exec=self.run,
|
||||
)
|
||||
self.register()
|
||||
|
||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Execute get-tag cmdlet."""
|
||||
# Parse arguments
|
||||
parsed = parse_cmdlet_args(args, self)
|
||||
|
||||
# Get hash and store from parsed args or result
|
||||
hash_override = parsed.get("hash")
|
||||
file_hash = hash_override or get_field(result, "hash") or get_field(result, "file_hash") or get_field(result, "hash_hex")
|
||||
storage_source = parsed.get("store") or get_field(result, "store") or get_field(result, "storage") or get_field(result, "origin")
|
||||
|
||||
if not file_hash:
|
||||
log("No hash available in result", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not storage_source:
|
||||
log("No storage backend specified in result", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Get tags using storage backend
|
||||
try:
|
||||
from helper.store import FileStorage
|
||||
storage_obj = FileStorage(config)
|
||||
backend = storage_obj[storage_source]
|
||||
current, source = backend.get_tag(file_hash, config=config)
|
||||
|
||||
if not current:
|
||||
log("No tags found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Build table and emit
|
||||
item_title = get_field(result, "title") or file_hash[:16]
|
||||
_emit_tags_as_table(
|
||||
tags_list=current,
|
||||
hash_hex=file_hash,
|
||||
source=source,
|
||||
service_name="",
|
||||
config=config,
|
||||
item_title=item_title,
|
||||
file_path=None,
|
||||
subject=result,
|
||||
)
|
||||
return 0
|
||||
|
||||
except KeyError:
|
||||
log(f"Storage backend '{storage_source}' not found", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as exc:
|
||||
log(f"Failed to get tags: {exc}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
# Create and register the cmdlet
|
||||
CMDLET = Get_Tag()
|
||||
|
||||
|
||||
|
||||
1415
cmdlets/get_tag.py.orig
Normal file
1415
cmdlets/get_tag.py.orig
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,139 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Sequence
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import register
|
||||
import models
|
||||
import pipeline as ctx
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash
|
||||
from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, get_field, normalize_hash
|
||||
from helper.logger import log
|
||||
from config import get_local_storage_path
|
||||
from helper.local_library import LocalLibraryDB
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="get-url",
|
||||
summary="List URLs associated with a file (Hydrus or Local).",
|
||||
usage="get-url [-hash <sha256>]",
|
||||
args=[
|
||||
CmdletArg("-hash", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."),
|
||||
],
|
||||
details=[
|
||||
"- Prints the known URLs for the selected file.",
|
||||
],
|
||||
)
|
||||
from helper.store import FileStorage
|
||||
|
||||
|
||||
def _parse_hash_and_rest(args: Sequence[str]) -> tuple[str | None, list[str]]:
|
||||
override_hash: str | None = None
|
||||
rest: list[str] = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
a = args[i]
|
||||
low = str(a).lower()
|
||||
if low in {"-hash", "--hash", "hash"} and i + 1 < len(args):
|
||||
override_hash = str(args[i + 1]).strip()
|
||||
i += 2
|
||||
continue
|
||||
rest.append(a)
|
||||
i += 1
|
||||
return override_hash, rest
|
||||
|
||||
|
||||
@register(["get-url", "get-urls", "get_url"]) # aliases
|
||||
def get_urls(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Helper to get field from both dict and object
|
||||
def get_field(obj: Any, field: str, default: Any = None) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(field, default)
|
||||
else:
|
||||
return getattr(obj, field, default)
|
||||
class Get_Url(Cmdlet):
|
||||
"""Get url associated with files via hash+store."""
|
||||
|
||||
# Help
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
NAME = "get-url"
|
||||
SUMMARY = "List url associated with a file"
|
||||
USAGE = "@1 | get-url"
|
||||
ARGS = [
|
||||
SharedArgs.HASH,
|
||||
SharedArgs.STORE,
|
||||
]
|
||||
DETAIL = [
|
||||
"- Lists all url associated with file identified by hash+store",
|
||||
]
|
||||
|
||||
override_hash, _ = _parse_hash_and_rest(args)
|
||||
|
||||
# Handle @N selection which creates a list - extract the first item
|
||||
if isinstance(result, list) and len(result) > 0:
|
||||
result = result[0]
|
||||
|
||||
found_urls = []
|
||||
|
||||
# 1. Try Local Library
|
||||
file_path = get_field(result, "file_path") or get_field(result, "path")
|
||||
if file_path and not override_hash:
|
||||
try:
|
||||
path_obj = Path(file_path)
|
||||
if path_obj.exists():
|
||||
storage_path = get_local_storage_path(config)
|
||||
if storage_path:
|
||||
with LocalLibraryDB(storage_path) as db:
|
||||
metadata = db.get_metadata(path_obj)
|
||||
if metadata and metadata.get("known_urls"):
|
||||
found_urls.extend(metadata["known_urls"])
|
||||
except Exception as e:
|
||||
log(f"Error checking local library: {e}", file=sys.stderr)
|
||||
|
||||
# 2. Try Hydrus
|
||||
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(get_field(result, "hash_hex", None))
|
||||
|
||||
# If we haven't found URLs yet, or if we want to merge them (maybe?), let's check Hydrus if we have a hash
|
||||
# But usually if it's local, we might not want to check Hydrus unless requested.
|
||||
# However, the user said "they can just work together".
|
||||
|
||||
if hash_hex:
|
||||
try:
|
||||
client = hydrus_wrapper.get_client(config)
|
||||
if client:
|
||||
payload = client.fetch_file_metadata(hashes=[hash_hex], include_file_urls=True)
|
||||
items = payload.get("metadata") if isinstance(payload, dict) else None
|
||||
meta = items[0] if (isinstance(items, list) and items and isinstance(items[0], dict)) else None
|
||||
hydrus_urls = (meta.get("known_urls") if isinstance(meta, dict) else None) or []
|
||||
for u in hydrus_urls:
|
||||
if u not in found_urls:
|
||||
found_urls.append(u)
|
||||
except Exception as exc:
|
||||
# Only log error if we didn't find local URLs either, or if it's a specific error
|
||||
if not found_urls:
|
||||
log(f"Hydrus lookup failed: {exc}", file=sys.stderr)
|
||||
|
||||
if found_urls:
|
||||
for u in found_urls:
|
||||
text = str(u).strip()
|
||||
if text:
|
||||
# Emit a rich object that looks like a string but carries context
|
||||
# We use a dict with 'title' which ResultTable uses for display
|
||||
# and 'url' which is the actual data
|
||||
# We also include the source file info so downstream cmdlets can use it
|
||||
|
||||
# Create a result object that mimics the structure expected by delete-url
|
||||
# delete-url expects a file object usually, but here we are emitting URLs.
|
||||
# If we emit a dict with 'url' and 'source_file', delete-url can use it.
|
||||
|
||||
rich_result = {
|
||||
"title": text, # Display as just the URL
|
||||
"url": text,
|
||||
"source_file": result, # Pass the original file context
|
||||
"file_path": get_field(result, "file_path") or get_field(result, "path"),
|
||||
"hash_hex": hash_hex
|
||||
}
|
||||
ctx.emit(rich_result)
|
||||
return 0
|
||||
|
||||
if not hash_hex and not file_path:
|
||||
log("Selected result does not include a file path or Hydrus hash", file=sys.stderr)
|
||||
return 1
|
||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Get url for file via hash+store backend."""
|
||||
parsed = parse_cmdlet_args(args, self)
|
||||
|
||||
ctx.emit("No URLs found.")
|
||||
return 0
|
||||
# Extract hash and store from result or args
|
||||
file_hash = parsed.get("hash") or get_field(result, "hash")
|
||||
store_name = parsed.get("store") or get_field(result, "store")
|
||||
|
||||
if not file_hash:
|
||||
log("Error: No file hash provided")
|
||||
return 1
|
||||
|
||||
if not store_name:
|
||||
log("Error: No store name provided")
|
||||
return 1
|
||||
|
||||
# Normalize hash
|
||||
file_hash = normalize_hash(file_hash)
|
||||
if not file_hash:
|
||||
log("Error: Invalid hash format")
|
||||
return 1
|
||||
|
||||
# Get backend and retrieve url
|
||||
try:
|
||||
storage = FileStorage(config)
|
||||
backend = storage[store_name]
|
||||
|
||||
url = backend.get_url(file_hash)
|
||||
|
||||
if url:
|
||||
for url in url:
|
||||
# Emit rich object for pipeline compatibility
|
||||
ctx.emit({
|
||||
"url": url,
|
||||
"hash": file_hash,
|
||||
"store": store_name,
|
||||
})
|
||||
return 0
|
||||
else:
|
||||
ctx.emit("No url found")
|
||||
return 0
|
||||
|
||||
except KeyError:
|
||||
log(f"Error: Storage backend '{store_name}' not configured")
|
||||
return 1
|
||||
except Exception as exc:
|
||||
log(f"Error retrieving url: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
# Register cmdlet
|
||||
register(["get-url", "get_url"])(Get_Url)
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ CMDLET = Cmdlet(
|
||||
name=".config",
|
||||
summary="Manage configuration settings",
|
||||
usage=".config [key] [value]",
|
||||
args=[
|
||||
arg=[
|
||||
CmdletArg(
|
||||
name="key",
|
||||
description="Configuration key to update (dot-separated)",
|
||||
|
||||
@@ -42,16 +42,14 @@ from ._shared import (
|
||||
normalize_result_input,
|
||||
get_pipe_object_path,
|
||||
get_pipe_object_hash,
|
||||
should_show_help,
|
||||
get_field,
|
||||
)
|
||||
import models
|
||||
import pipeline as ctx
|
||||
|
||||
|
||||
def _get_item_value(item: Any, key: str, default: Any = None) -> Any:
|
||||
"""Helper to read either dict keys or attributes."""
|
||||
if isinstance(item, dict):
|
||||
return item.get(key, default)
|
||||
return getattr(item, key, default)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -60,12 +58,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Merge multiple files into one."""
|
||||
|
||||
# Parse help
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
if should_show_help(args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
# Parse arguments
|
||||
parsed = parse_cmdlet_args(args, CMDLET)
|
||||
@@ -102,7 +97,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
source_files: List[Path] = []
|
||||
source_tags_files: List[Path] = []
|
||||
source_hashes: List[str] = []
|
||||
source_urls: List[str] = []
|
||||
source_url: List[str] = []
|
||||
source_tags: List[str] = [] # NEW: collect tags from source files
|
||||
source_relationships: List[str] = [] # NEW: collect relationships from source files
|
||||
|
||||
@@ -146,7 +141,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if tags_file.exists():
|
||||
source_tags_files.append(tags_file)
|
||||
|
||||
# Try to read hash, tags, urls, and relationships from .tags sidecar file
|
||||
# Try to read hash, tags, url, and relationships from .tags sidecar file
|
||||
try:
|
||||
tags_content = tags_file.read_text(encoding='utf-8')
|
||||
for line in tags_content.split('\n'):
|
||||
@@ -157,18 +152,18 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
hash_value = line[5:].strip()
|
||||
if hash_value:
|
||||
source_hashes.append(hash_value)
|
||||
elif line.startswith('known_url:') or line.startswith('url:'):
|
||||
# Extract URLs from tags file
|
||||
elif line.startswith('url:') or line.startswith('url:'):
|
||||
# Extract url from tags file
|
||||
url_value = line.split(':', 1)[1].strip() if ':' in line else ''
|
||||
if url_value and url_value not in source_urls:
|
||||
source_urls.append(url_value)
|
||||
if url_value and url_value not in source_url:
|
||||
source_url.append(url_value)
|
||||
elif line.startswith('relationship:'):
|
||||
# Extract relationships from tags file
|
||||
rel_value = line.split(':', 1)[1].strip() if ':' in line else ''
|
||||
if rel_value and rel_value not in source_relationships:
|
||||
source_relationships.append(rel_value)
|
||||
else:
|
||||
# Collect actual tags (not metadata like hash: or known_url:)
|
||||
# Collect actual tags (not metadata like hash: or url:)
|
||||
source_tags.append(line)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -178,14 +173,14 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if hash_value and hash_value not in source_hashes:
|
||||
source_hashes.append(str(hash_value))
|
||||
|
||||
# Extract known URLs if available
|
||||
known_urls = _get_item_value(item, 'known_urls', [])
|
||||
if isinstance(known_urls, str):
|
||||
source_urls.append(known_urls)
|
||||
elif isinstance(known_urls, list):
|
||||
source_urls.extend(known_urls)
|
||||
# Extract known url if available
|
||||
url = get_field(item, 'url', [])
|
||||
if isinstance(url, str):
|
||||
source_url.append(url)
|
||||
elif isinstance(url, list):
|
||||
source_url.extend(url)
|
||||
else:
|
||||
title = _get_item_value(item, 'title', 'unknown') or _get_item_value(item, 'id', 'unknown')
|
||||
title = get_field(item, 'title', 'unknown') or get_field(item, 'id', 'unknown')
|
||||
log(f"Warning: Could not locate file for item: {title}", file=sys.stderr)
|
||||
|
||||
if len(source_files) < 2:
|
||||
@@ -279,8 +274,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if HAS_METADATA_API and write_tags_to_file:
|
||||
# Use unified API for file writing
|
||||
source_hashes_list = source_hashes if source_hashes else None
|
||||
source_urls_list = source_urls if source_urls else None
|
||||
write_tags_to_file(tags_path, merged_tags, source_hashes_list, source_urls_list)
|
||||
source_url_list = source_url if source_url else None
|
||||
write_tags_to_file(tags_path, merged_tags, source_hashes_list, source_url_list)
|
||||
else:
|
||||
# Fallback: manual file writing
|
||||
tags_lines = []
|
||||
@@ -292,10 +287,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Add regular tags
|
||||
tags_lines.extend(merged_tags)
|
||||
|
||||
# Add known URLs
|
||||
if source_urls:
|
||||
for url in source_urls:
|
||||
tags_lines.append(f"known_url:{url}")
|
||||
# Add known url
|
||||
if source_url:
|
||||
for url in source_url:
|
||||
tags_lines.append(f"url:{url}")
|
||||
|
||||
# Add relationships (if available)
|
||||
if source_relationships:
|
||||
@@ -309,7 +304,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
# Also create .metadata file using centralized function
|
||||
try:
|
||||
write_metadata(output_path, source_hashes[0] if source_hashes else None, source_urls, source_relationships)
|
||||
write_metadata(output_path, source_hashes[0] if source_hashes else None, source_url, source_relationships)
|
||||
log(f"Created metadata: {output_path.name}.metadata", file=sys.stderr)
|
||||
except Exception as e:
|
||||
log(f"Warning: Could not create metadata file: {e}", file=sys.stderr)
|
||||
@@ -325,12 +320,12 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
except ImportError:
|
||||
# Fallback: create a simple object with the required attributes
|
||||
class SimpleItem:
|
||||
def __init__(self, target, title, media_kind, tags=None, known_urls=None):
|
||||
def __init__(self, target, title, media_kind, tags=None, url=None):
|
||||
self.target = target
|
||||
self.title = title
|
||||
self.media_kind = media_kind
|
||||
self.tags = tags or []
|
||||
self.known_urls = known_urls or []
|
||||
self.url = url or []
|
||||
self.origin = "local" # Ensure origin is set for add-file
|
||||
PipelineItem = SimpleItem
|
||||
|
||||
@@ -339,7 +334,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
title=output_path.stem,
|
||||
media_kind=file_kind,
|
||||
tags=merged_tags, # Include merged tags
|
||||
known_urls=source_urls # Include known URLs
|
||||
url=source_url # Include known url
|
||||
)
|
||||
# Clear previous results to ensure only the merged file is passed down
|
||||
ctx.clear_last_result()
|
||||
@@ -904,12 +899,12 @@ CMDLET = Cmdlet(
|
||||
name="merge-file",
|
||||
summary="Merge multiple files into a single output file. Supports audio, video, PDF, and text merging with optional cleanup.",
|
||||
usage="merge-file [-delete] [-output <path>] [-format <auto|mp3|aac|opus|mp4|mkv|pdf|txt>]",
|
||||
args=[
|
||||
arg=[
|
||||
CmdletArg("-delete", type="flag", description="Delete source files after successful merge."),
|
||||
CmdletArg("-output", description="Override output file path."),
|
||||
CmdletArg("-format", description="Output format (auto/mp3/aac/opus/mp4/mkv/pdf/txt). Default: auto-detect from first file."),
|
||||
],
|
||||
details=[
|
||||
detail=[
|
||||
"- Pipe multiple files: search-file query | [1,2,3] | merge-file",
|
||||
"- Audio files merge with minimal quality loss using specified codec.",
|
||||
"- Video files merge into MP4 or MKV containers.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Screen-shot cmdlet for capturing screenshots of URLs in a pipeline.
|
||||
"""Screen-shot cmdlet for capturing screenshots of url in a pipeline.
|
||||
|
||||
This cmdlet processes files through the pipeline and creates screenshots using
|
||||
Playwright, marking them as temporary artifacts for cleanup.
|
||||
@@ -23,7 +23,7 @@ from helper.http_client import HTTPClient
|
||||
from helper.utils import ensure_directory, unique_path, unique_preserve_order
|
||||
|
||||
from . import register
|
||||
from ._shared import Cmdlet, CmdletArg, SharedArgs, create_pipe_object_result, normalize_result_input
|
||||
from ._shared import Cmdlet, CmdletArg, SharedArgs, create_pipe_object_result, normalize_result_input, should_show_help, get_field
|
||||
import models
|
||||
import pipeline as pipeline_context
|
||||
|
||||
@@ -113,8 +113,8 @@ class ScreenshotError(RuntimeError):
|
||||
class ScreenshotOptions:
|
||||
"""Options controlling screenshot capture and post-processing."""
|
||||
|
||||
url: str
|
||||
output_dir: Path
|
||||
url: Sequence[str] = ()
|
||||
output_path: Optional[Path] = None
|
||||
full_page: bool = True
|
||||
headless: bool = True
|
||||
@@ -124,7 +124,7 @@ class ScreenshotOptions:
|
||||
tags: Sequence[str] = ()
|
||||
archive: bool = False
|
||||
archive_timeout: float = ARCHIVE_TIMEOUT
|
||||
known_urls: Sequence[str] = ()
|
||||
url: Sequence[str] = ()
|
||||
output_format: Optional[str] = None
|
||||
prefer_platform_target: bool = False
|
||||
target_selectors: Optional[Sequence[str]] = None
|
||||
@@ -136,10 +136,9 @@ class ScreenshotResult:
|
||||
"""Details about the captured screenshot."""
|
||||
|
||||
path: Path
|
||||
url: str
|
||||
tags_applied: List[str]
|
||||
archive_urls: List[str]
|
||||
known_urls: List[str]
|
||||
archive_url: List[str]
|
||||
url: List[str]
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@@ -471,24 +470,24 @@ def _capture_screenshot(options: ScreenshotOptions) -> ScreenshotResult:
|
||||
warnings: List[str] = []
|
||||
_capture(options, destination, warnings)
|
||||
|
||||
known_urls = unique_preserve_order([options.url, *options.known_urls])
|
||||
archive_urls: List[str] = []
|
||||
# Build URL list from provided options.url (sequence) and deduplicate
|
||||
url = unique_preserve_order(list(options.url))
|
||||
archive_url: List[str] = []
|
||||
if options.archive:
|
||||
debug(f"[_capture_screenshot] Archiving enabled for {options.url}")
|
||||
archives, archive_warnings = _archive_url(options.url, options.archive_timeout)
|
||||
archive_urls.extend(archives)
|
||||
archive_url.extend(archives)
|
||||
warnings.extend(archive_warnings)
|
||||
if archives:
|
||||
known_urls = unique_preserve_order([*known_urls, *archives])
|
||||
url = unique_preserve_order([*url, *archives])
|
||||
|
||||
applied_tags = unique_preserve_order(list(tag for tag in options.tags if tag.strip()))
|
||||
|
||||
return ScreenshotResult(
|
||||
path=destination,
|
||||
url=options.url,
|
||||
tags_applied=applied_tags,
|
||||
archive_urls=archive_urls,
|
||||
known_urls=known_urls,
|
||||
archive_url=archive_url,
|
||||
url=url,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
@@ -498,10 +497,10 @@ def _capture_screenshot(options: ScreenshotOptions) -> ScreenshotResult:
|
||||
# ============================================================================
|
||||
|
||||
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Take screenshots of URLs in the pipeline.
|
||||
"""Take screenshots of url in the pipeline.
|
||||
|
||||
Accepts:
|
||||
- Single result object (dict or PipeObject) with 'file_path' field
|
||||
- Single result object (dict or PipeObject) with 'path' field
|
||||
- List of result objects to screenshot each
|
||||
- Direct URL as string
|
||||
|
||||
@@ -518,12 +517,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
debug(f"[_run] screen-shot invoked with args: {args}")
|
||||
|
||||
# Help check
|
||||
try:
|
||||
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
if should_show_help(args):
|
||||
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
# ========================================================================
|
||||
# ARGUMENT PARSING
|
||||
@@ -539,36 +535,36 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
# Positional URL argument (if provided)
|
||||
url_arg = parsed.get("url")
|
||||
positional_urls = [str(url_arg)] if url_arg else []
|
||||
positional_url = [str(url_arg)] if url_arg else []
|
||||
|
||||
# ========================================================================
|
||||
# INPUT PROCESSING - Extract URLs from pipeline or command arguments
|
||||
# INPUT PROCESSING - Extract url from pipeline or command arguments
|
||||
# ========================================================================
|
||||
|
||||
piped_results = normalize_result_input(result)
|
||||
urls_to_process = []
|
||||
url_to_process = []
|
||||
|
||||
# Extract URLs from piped results
|
||||
# Extract url from piped results
|
||||
if piped_results:
|
||||
for item in piped_results:
|
||||
url = None
|
||||
if isinstance(item, dict):
|
||||
url = item.get('file_path') or item.get('path') or item.get('url') or item.get('target')
|
||||
else:
|
||||
url = getattr(item, 'file_path', None) or getattr(item, 'path', None) or getattr(item, 'url', None) or getattr(item, 'target', None)
|
||||
|
||||
url = (
|
||||
get_field(item, 'path')
|
||||
or get_field(item, 'url')
|
||||
or get_field(item, 'target')
|
||||
)
|
||||
|
||||
if url:
|
||||
urls_to_process.append(str(url))
|
||||
url_to_process.append(str(url))
|
||||
|
||||
# Use positional arguments if no pipeline input
|
||||
if not urls_to_process and positional_urls:
|
||||
urls_to_process = positional_urls
|
||||
if not url_to_process and positional_url:
|
||||
url_to_process = positional_url
|
||||
|
||||
if not urls_to_process:
|
||||
log(f"No URLs to process for screen-shot cmdlet", file=sys.stderr)
|
||||
if not url_to_process:
|
||||
log(f"No url to process for screen-shot cmdlet", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
debug(f"[_run] URLs to process: {urls_to_process}")
|
||||
debug(f"[_run] url to process: {url_to_process}")
|
||||
|
||||
# ========================================================================
|
||||
# OUTPUT DIRECTORY RESOLUTION - Priority chain
|
||||
@@ -619,10 +615,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
all_emitted = []
|
||||
exit_code = 0
|
||||
# ========================================================================
|
||||
# PROCESS URLs AND CAPTURE SCREENSHOTS
|
||||
# PROCESS url AND CAPTURE SCREENSHOTS
|
||||
# ========================================================================
|
||||
|
||||
for url in urls_to_process:
|
||||
for url in url_to_process:
|
||||
# Validate URL format
|
||||
if not url.lower().startswith(("http://", "https://", "file://")):
|
||||
log(f"[screen_shot] Skipping non-URL input: {url}", file=sys.stderr)
|
||||
@@ -631,7 +627,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
try:
|
||||
# Create screenshot with provided options
|
||||
options = ScreenshotOptions(
|
||||
url=url,
|
||||
url=[url],
|
||||
output_dir=screenshot_dir,
|
||||
output_format=format_name,
|
||||
archive=archive_enabled,
|
||||
@@ -645,8 +641,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
# Log results and warnings
|
||||
log(f"Screenshot captured to {screenshot_result.path}", flush=True)
|
||||
if screenshot_result.archive_urls:
|
||||
log(f"Archives: {', '.join(screenshot_result.archive_urls)}", flush=True)
|
||||
if screenshot_result.archive_url:
|
||||
log(f"Archives: {', '.join(screenshot_result.archive_url)}", flush=True)
|
||||
for warning in screenshot_result.warnings:
|
||||
log(f"Warning: {warning}", flush=True)
|
||||
|
||||
@@ -670,8 +666,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
parent_hash=hashlib.sha256(url.encode()).hexdigest(),
|
||||
extra={
|
||||
'source_url': url,
|
||||
'archive_urls': screenshot_result.archive_urls,
|
||||
'known_urls': screenshot_result.known_urls,
|
||||
'archive_url': screenshot_result.archive_url,
|
||||
'url': screenshot_result.url,
|
||||
'target': str(screenshot_result.path), # Explicit target for add-file
|
||||
}
|
||||
)
|
||||
@@ -701,16 +697,16 @@ CMDLET = Cmdlet(
|
||||
name="screen-shot",
|
||||
summary="Capture a screenshot of a URL or file and mark as temporary artifact",
|
||||
usage="screen-shot <url> [options] or download-data <url> | screen-shot [options]",
|
||||
aliases=["screenshot", "ss"],
|
||||
args=[
|
||||
alias=["screenshot", "ss"],
|
||||
arg=[
|
||||
CmdletArg(name="url", type="string", required=False, description="URL to screenshot (or from pipeline)"),
|
||||
CmdletArg(name="format", type="string", description="Output format: png, jpeg, or pdf"),
|
||||
CmdletArg(name="selector", type="string", description="CSS selector for element capture"),
|
||||
SharedArgs.ARCHIVE, # Use shared archive argument
|
||||
SharedArgs.STORAGE, # Use shared storage argument
|
||||
SharedArgs.STORE, # Use shared storage argument
|
||||
],
|
||||
details=[
|
||||
"Take screenshots of URLs with optional archiving and element targeting.",
|
||||
detail=[
|
||||
"Take screenshots of url with optional archiving and element targeting.",
|
||||
"Screenshots are marked as temporary artifacts for cleanup by the cleanup cmdlet.",
|
||||
"",
|
||||
"Arguments:",
|
||||
|
||||
@@ -1,531 +0,0 @@
|
||||
"""Search-file cmdlet: Search for files by query, tags, size, type, duration, etc."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Sequence, List, Optional, Tuple, Callable
|
||||
from fnmatch import fnmatchcase
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from collections import OrderedDict
|
||||
import re
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from helper.logger import log, debug
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from helper.file_storage import FileStorage
|
||||
from helper.search_provider import get_provider, list_providers, SearchResult
|
||||
from metadata import import_pending_sidecars
|
||||
|
||||
from . import register
|
||||
from ._shared import Cmdlet, CmdletArg
|
||||
import models
|
||||
import pipeline as ctx
|
||||
|
||||
# Optional dependencies
|
||||
try:
|
||||
import mutagen # type: ignore
|
||||
except ImportError: # pragma: no cover
|
||||
mutagen = None # type: ignore
|
||||
|
||||
try:
|
||||
from config import get_hydrus_url, resolve_output_dir
|
||||
except Exception: # pragma: no cover
|
||||
get_hydrus_url = None # type: ignore
|
||||
resolve_output_dir = None # type: ignore
|
||||
|
||||
try:
|
||||
from helper.hydrus import HydrusClient, HydrusRequestError
|
||||
except ImportError: # pragma: no cover
|
||||
HydrusClient = None # type: ignore
|
||||
HydrusRequestError = RuntimeError # type: ignore
|
||||
|
||||
try:
|
||||
from helper.utils import sha256_file
|
||||
except ImportError: # pragma: no cover
|
||||
sha256_file = None # type: ignore
|
||||
|
||||
try:
|
||||
from helper.utils_constant import mime_maps
|
||||
except ImportError: # pragma: no cover
|
||||
mime_maps = {} # type: ignore
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Data Classes (from helper/search.py)
|
||||
# ============================================================================
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SearchRecord:
|
||||
path: str
|
||||
size_bytes: int | None = None
|
||||
duration_seconds: str | None = None
|
||||
tags: str | None = None
|
||||
hash_hex: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
payload: dict[str, str] = {"path": self.path}
|
||||
if self.size_bytes is not None:
|
||||
payload["size"] = str(self.size_bytes)
|
||||
if self.duration_seconds:
|
||||
payload["duration"] = self.duration_seconds
|
||||
if self.tags:
|
||||
payload["tags"] = self.tags
|
||||
if self.hash_hex:
|
||||
payload["hash"] = self.hash_hex
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResultItem:
|
||||
origin: str
|
||||
title: str
|
||||
detail: str
|
||||
annotations: List[str]
|
||||
target: str
|
||||
media_kind: str = "other"
|
||||
hash_hex: Optional[str] = None
|
||||
columns: List[tuple[str, str]] = field(default_factory=list)
|
||||
tag_summary: Optional[str] = None
|
||||
duration_seconds: Optional[float] = None
|
||||
size_bytes: Optional[int] = None
|
||||
full_metadata: Optional[Dict[str, Any]] = None
|
||||
tags: Optional[set[str]] = field(default_factory=set)
|
||||
relationships: Optional[List[str]] = field(default_factory=list)
|
||||
known_urls: Optional[List[str]] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"title": self.title,
|
||||
}
|
||||
|
||||
# Always include these core fields for downstream cmdlets (get-file, download-data, etc)
|
||||
payload["origin"] = self.origin
|
||||
payload["target"] = self.target
|
||||
payload["media_kind"] = self.media_kind
|
||||
|
||||
# Always include full_metadata if present (needed by download-data, etc)
|
||||
# This is NOT for display, but for downstream processing
|
||||
if self.full_metadata:
|
||||
payload["full_metadata"] = self.full_metadata
|
||||
|
||||
# Include columns if defined (result renderer will use these for display)
|
||||
if self.columns:
|
||||
payload["columns"] = list(self.columns)
|
||||
else:
|
||||
# If no columns, include the detail for backwards compatibility
|
||||
payload["detail"] = self.detail
|
||||
payload["annotations"] = list(self.annotations)
|
||||
|
||||
# Include optional fields
|
||||
if self.hash_hex:
|
||||
payload["hash"] = self.hash_hex
|
||||
if self.tag_summary:
|
||||
payload["tags"] = self.tag_summary
|
||||
if self.tags:
|
||||
payload["tags_set"] = list(self.tags)
|
||||
if self.relationships:
|
||||
payload["relationships"] = self.relationships
|
||||
if self.known_urls:
|
||||
payload["known_urls"] = self.known_urls
|
||||
return payload
|
||||
|
||||
|
||||
STORAGE_ORIGINS = {"local", "hydrus", "debrid"}
|
||||
|
||||
|
||||
def _normalize_extension(ext_value: Any) -> str:
|
||||
"""Sanitize extension strings to alphanumerics and cap at 5 chars."""
|
||||
ext = str(ext_value or "").strip().lstrip(".")
|
||||
|
||||
# Stop at common separators to avoid dragging status text into the extension
|
||||
for sep in (" ", "|", "(", "[", "{", ",", ";"):
|
||||
if sep in ext:
|
||||
ext = ext.split(sep, 1)[0]
|
||||
break
|
||||
|
||||
# If there are multiple dots, take the last token as the extension
|
||||
if "." in ext:
|
||||
ext = ext.split(".")[-1]
|
||||
|
||||
# Keep only alphanumeric characters and enforce max length
|
||||
ext = "".join(ch for ch in ext if ch.isalnum())
|
||||
return ext[:5]
|
||||
|
||||
|
||||
def _ensure_storage_columns(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Attach Title/Store columns for storage-origin results to keep CLI display compact."""
|
||||
origin_value = str(payload.get("origin") or payload.get("source") or "").lower()
|
||||
if origin_value not in STORAGE_ORIGINS:
|
||||
return payload
|
||||
|
||||
title = payload.get("title") or payload.get("name") or payload.get("target") or payload.get("path") or "Result"
|
||||
store_label = payload.get("origin") or payload.get("source") or origin_value
|
||||
|
||||
# Handle extension
|
||||
extension = _normalize_extension(payload.get("ext", ""))
|
||||
if not extension and title:
|
||||
path_obj = Path(str(title))
|
||||
if path_obj.suffix:
|
||||
extension = _normalize_extension(path_obj.suffix.lstrip('.'))
|
||||
title = path_obj.stem
|
||||
|
||||
# Handle size as integer MB (header will include units)
|
||||
size_val = payload.get("size") or payload.get("size_bytes")
|
||||
size_str = ""
|
||||
if size_val is not None:
|
||||
try:
|
||||
size_bytes = int(size_val)
|
||||
size_mb = int(size_bytes / (1024 * 1024))
|
||||
size_str = str(size_mb)
|
||||
except (ValueError, TypeError):
|
||||
size_str = str(size_val)
|
||||
|
||||
normalized = dict(payload)
|
||||
normalized["columns"] = [
|
||||
("Title", str(title)),
|
||||
("Ext", str(extension)),
|
||||
("Store", str(store_label)),
|
||||
("Size(Mb)", str(size_str)),
|
||||
]
|
||||
return normalized
|
||||
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="search-file",
|
||||
summary="Unified search cmdlet for storage (Hydrus, Local) and providers (Debrid, LibGen, OpenLibrary, Soulseek).",
|
||||
usage="search-file [query] [-tag TAG] [-size >100MB|<50MB] [-type audio|video|image] [-duration >10:00] [-storage BACKEND] [-provider PROVIDER]",
|
||||
args=[
|
||||
CmdletArg("query", description="Search query string"),
|
||||
CmdletArg("tag", description="Filter by tag (can be used multiple times)"),
|
||||
CmdletArg("size", description="Filter by size: >100MB, <50MB, =10MB"),
|
||||
CmdletArg("type", description="Filter by type: audio, video, image, document"),
|
||||
CmdletArg("duration", description="Filter by duration: >10:00, <1:30:00"),
|
||||
CmdletArg("limit", type="integer", description="Limit results (default: 45)"),
|
||||
CmdletArg("storage", description="Search storage backend: hydrus, local (default: all searchable storages)"),
|
||||
CmdletArg("provider", description="Search provider: libgen, openlibrary, soulseek, debrid, local (overrides -storage)"),
|
||||
],
|
||||
details=[
|
||||
"Search across storage (Hydrus, Local) and providers (Debrid, LibGen, OpenLibrary, Soulseek)",
|
||||
"Use -provider to search a specific source, or -storage to search file backends",
|
||||
"Filter results by: tag, size, type, duration",
|
||||
"Results can be piped to other commands",
|
||||
"Examples:",
|
||||
"search-file foo # Search all file backends",
|
||||
"search-file -provider libgen 'python programming' # Search LibGen books",
|
||||
"search-file -provider debrid 'movie' # Search AllDebrid magnets",
|
||||
"search-file 'music' -provider soulseek # Search Soulseek P2P",
|
||||
"search-file -provider openlibrary 'tolkien' # Search OpenLibrary",
|
||||
"search-file song -storage hydrus -type audio # Search only Hydrus audio",
|
||||
"search-file movie -tag action -provider debrid # Debrid with filters",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register(["search-file", "search"])
|
||||
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Search across multiple providers: Hydrus, Local, Debrid, LibGen, etc."""
|
||||
args_list = [str(arg) for arg in (args or [])]
|
||||
|
||||
# Parse arguments
|
||||
query = ""
|
||||
tag_filters: List[str] = []
|
||||
size_filter: Optional[Tuple[str, int]] = None
|
||||
duration_filter: Optional[Tuple[str, float]] = None
|
||||
type_filter: Optional[str] = None
|
||||
storage_backend: Optional[str] = None
|
||||
provider_name: Optional[str] = None
|
||||
limit = 45
|
||||
searched_backends: List[str] = []
|
||||
|
||||
# Simple argument parsing
|
||||
i = 0
|
||||
while i < len(args_list):
|
||||
arg = args_list[i]
|
||||
low = arg.lower()
|
||||
|
||||
if low in {"-provider", "--provider"} and i + 1 < len(args_list):
|
||||
provider_name = args_list[i + 1].lower()
|
||||
i += 2
|
||||
elif low in {"-storage", "--storage"} and i + 1 < len(args_list):
|
||||
storage_backend = args_list[i + 1].lower()
|
||||
i += 2
|
||||
elif low in {"-tag", "--tag"} and i + 1 < len(args_list):
|
||||
tag_filters.append(args_list[i + 1])
|
||||
i += 2
|
||||
elif low in {"-limit", "--limit"} and i + 1 < len(args_list):
|
||||
try:
|
||||
limit = int(args_list[i + 1])
|
||||
except ValueError:
|
||||
limit = 100
|
||||
i += 2
|
||||
elif low in {"-type", "--type"} and i + 1 < len(args_list):
|
||||
type_filter = args_list[i + 1].lower()
|
||||
i += 2
|
||||
elif not arg.startswith("-"):
|
||||
if query:
|
||||
query += " " + arg
|
||||
else:
|
||||
query = arg
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Extract store: filter tokens (works with commas or whitespace) and clean query for backends
|
||||
store_filter: Optional[str] = None
|
||||
if query:
|
||||
match = re.search(r"\bstore:([^\s,]+)", query, flags=re.IGNORECASE)
|
||||
if match:
|
||||
store_filter = match.group(1).strip().lower() or None
|
||||
# Remove any store: tokens so downstream backends see only the actual query
|
||||
query = re.sub(r"\s*[,]?\s*store:[^\s,]+", " ", query, flags=re.IGNORECASE)
|
||||
query = re.sub(r"\s{2,}", " ", query)
|
||||
query = query.strip().strip(',')
|
||||
|
||||
# Debrid is provider-only now
|
||||
if storage_backend and storage_backend.lower() == "debrid":
|
||||
log("Use -provider debrid instead of -storage debrid (debrid is provider-only)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# If store: was provided without explicit -storage/-provider, prefer that backend
|
||||
if store_filter and not provider_name and not storage_backend:
|
||||
if store_filter in {"hydrus", "local", "debrid"}:
|
||||
storage_backend = store_filter
|
||||
|
||||
# Handle piped input (e.g. from @N selection) if query is empty
|
||||
if not query and result:
|
||||
# If result is a list, take the first item
|
||||
actual_result = result[0] if isinstance(result, list) and result else result
|
||||
|
||||
# Helper to get field
|
||||
def get_field(obj: Any, field: str) -> Any:
|
||||
return getattr(obj, field, None) or (obj.get(field) if isinstance(obj, dict) else None)
|
||||
|
||||
origin = get_field(actual_result, 'origin')
|
||||
target = get_field(actual_result, 'target')
|
||||
|
||||
# Special handling for Bandcamp artist/album drill-down
|
||||
if origin == 'bandcamp' and target:
|
||||
query = target
|
||||
if not provider_name:
|
||||
provider_name = 'bandcamp'
|
||||
|
||||
# Generic URL handling
|
||||
elif target and str(target).startswith(('http://', 'https://')):
|
||||
query = target
|
||||
# Try to infer provider from URL if not set
|
||||
if not provider_name:
|
||||
if 'bandcamp.com' in target:
|
||||
provider_name = 'bandcamp'
|
||||
elif 'youtube.com' in target or 'youtu.be' in target:
|
||||
provider_name = 'youtube'
|
||||
|
||||
if not query:
|
||||
log("Provide a search query", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Initialize worker for this search command
|
||||
from helper.local_library import LocalLibraryDB
|
||||
from config import get_local_storage_path
|
||||
import uuid
|
||||
worker_id = str(uuid.uuid4())
|
||||
library_root = get_local_storage_path(config or {})
|
||||
if not library_root:
|
||||
log("No library root configured", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
db = None
|
||||
try:
|
||||
db = LocalLibraryDB(library_root)
|
||||
db.insert_worker(
|
||||
worker_id,
|
||||
"search",
|
||||
title=f"Search: {query}",
|
||||
description=f"Query: {query}",
|
||||
pipe=ctx.get_current_command_text()
|
||||
)
|
||||
|
||||
results_list = []
|
||||
import result_table
|
||||
import importlib
|
||||
importlib.reload(result_table)
|
||||
from result_table import ResultTable
|
||||
|
||||
# Create ResultTable for display
|
||||
table_title = f"Search: {query}"
|
||||
if provider_name:
|
||||
table_title += f" [{provider_name}]"
|
||||
elif storage_backend:
|
||||
table_title += f" [{storage_backend}]"
|
||||
|
||||
table = ResultTable(table_title)
|
||||
table.set_source_command("search-file", args_list)
|
||||
|
||||
# Try to search using provider (libgen, soulseek, debrid, openlibrary)
|
||||
if provider_name:
|
||||
debug(f"[search_file] Attempting provider search with: {provider_name}")
|
||||
provider = get_provider(provider_name, config)
|
||||
if not provider:
|
||||
log(f"Provider '{provider_name}' not available", file=sys.stderr)
|
||||
db.update_worker_status(worker_id, 'error')
|
||||
return 1
|
||||
|
||||
debug(f"[search_file] Provider loaded, calling search with query: {query}")
|
||||
search_result = provider.search(query, limit=limit)
|
||||
debug(f"[search_file] Provider search returned {len(search_result)} results")
|
||||
|
||||
for item in search_result:
|
||||
# Add to table
|
||||
table.add_result(item)
|
||||
|
||||
# Emit to pipeline
|
||||
item_dict = item.to_dict()
|
||||
results_list.append(item_dict)
|
||||
ctx.emit(item_dict)
|
||||
|
||||
# Set the result table in context for TUI/CLI display
|
||||
ctx.set_last_result_table(table, results_list)
|
||||
|
||||
debug(f"[search_file] Emitted {len(results_list)} results")
|
||||
|
||||
# Write results to worker stdout
|
||||
db.append_worker_stdout(worker_id, json.dumps(results_list, indent=2))
|
||||
db.update_worker_status(worker_id, 'completed')
|
||||
return 0
|
||||
|
||||
# Otherwise search using storage backends (Hydrus, Local)
|
||||
from helper.file_storage import FileStorage
|
||||
storage = FileStorage(config=config or {})
|
||||
|
||||
backend_to_search = storage_backend or None
|
||||
if backend_to_search:
|
||||
# Check if requested backend is available
|
||||
if backend_to_search == "hydrus":
|
||||
from helper.hydrus import is_hydrus_available
|
||||
if not is_hydrus_available(config or {}):
|
||||
log(f"Backend 'hydrus' is not available (Hydrus service not running)", file=sys.stderr)
|
||||
db.update_worker_status(worker_id, 'error')
|
||||
return 1
|
||||
searched_backends.append(backend_to_search)
|
||||
if not storage.supports_search(backend_to_search):
|
||||
log(f"Backend '{backend_to_search}' does not support searching", file=sys.stderr)
|
||||
db.update_worker_status(worker_id, 'error')
|
||||
return 1
|
||||
results = storage[backend_to_search].search(query, limit=limit)
|
||||
else:
|
||||
# Search all searchable backends, but skip hydrus if unavailable
|
||||
from helper.hydrus import is_hydrus_available
|
||||
hydrus_available = is_hydrus_available(config or {})
|
||||
|
||||
all_results = []
|
||||
for backend_name in storage.list_searchable_backends():
|
||||
# Skip hydrus if not available
|
||||
if backend_name == "hydrus" and not hydrus_available:
|
||||
continue
|
||||
searched_backends.append(backend_name)
|
||||
try:
|
||||
backend_results = storage[backend_name].search(query, limit=limit - len(all_results))
|
||||
if backend_results:
|
||||
all_results.extend(backend_results)
|
||||
if len(all_results) >= limit:
|
||||
break
|
||||
except Exception as exc:
|
||||
log(f"Backend {backend_name} search failed: {exc}", file=sys.stderr)
|
||||
results = all_results[:limit]
|
||||
|
||||
# Also query Debrid provider by default (provider-only, but keep legacy coverage when no explicit provider given)
|
||||
if not provider_name and not storage_backend:
|
||||
try:
|
||||
debrid_provider = get_provider("debrid", config)
|
||||
if debrid_provider and debrid_provider.validate():
|
||||
remaining = max(0, limit - len(results)) if isinstance(results, list) else limit
|
||||
if remaining > 0:
|
||||
debrid_results = debrid_provider.search(query, limit=remaining)
|
||||
if debrid_results:
|
||||
if "debrid" not in searched_backends:
|
||||
searched_backends.append("debrid")
|
||||
if results is None:
|
||||
results = []
|
||||
results.extend(debrid_results)
|
||||
except Exception as exc:
|
||||
log(f"Debrid provider search failed: {exc}", file=sys.stderr)
|
||||
|
||||
def _format_storage_label(name: str) -> str:
|
||||
clean = str(name or "").strip()
|
||||
if not clean:
|
||||
return "Unknown"
|
||||
return clean.replace("_", " ").title()
|
||||
|
||||
storage_counts: OrderedDict[str, int] = OrderedDict((name, 0) for name in searched_backends)
|
||||
for item in results or []:
|
||||
origin = getattr(item, 'origin', None)
|
||||
if origin is None and isinstance(item, dict):
|
||||
origin = item.get('origin') or item.get('source')
|
||||
if not origin:
|
||||
continue
|
||||
key = str(origin).lower()
|
||||
if key not in storage_counts:
|
||||
storage_counts[key] = 0
|
||||
storage_counts[key] += 1
|
||||
|
||||
if storage_counts or query:
|
||||
display_counts = OrderedDict((_format_storage_label(name), count) for name, count in storage_counts.items())
|
||||
summary_line = table.set_storage_summary(display_counts, query, inline=True)
|
||||
if summary_line:
|
||||
table.title = summary_line
|
||||
|
||||
# Emit results and collect for workers table
|
||||
if results:
|
||||
for item in results:
|
||||
def _as_dict(obj: Any) -> Dict[str, Any]:
|
||||
if isinstance(obj, dict):
|
||||
return dict(obj)
|
||||
if hasattr(obj, "to_dict") and callable(getattr(obj, "to_dict")):
|
||||
return obj.to_dict() # type: ignore[arg-type]
|
||||
return {"title": str(obj)}
|
||||
|
||||
item_dict = _as_dict(item)
|
||||
if store_filter:
|
||||
origin_val = str(item_dict.get("origin") or item_dict.get("source") or "").lower()
|
||||
if store_filter != origin_val:
|
||||
continue
|
||||
normalized = _ensure_storage_columns(item_dict)
|
||||
# Add to table using normalized columns to avoid extra fields (e.g., Tags/Name)
|
||||
table.add_result(normalized)
|
||||
|
||||
results_list.append(normalized)
|
||||
ctx.emit(normalized)
|
||||
|
||||
# Set the result table in context for TUI/CLI display
|
||||
ctx.set_last_result_table(table, results_list)
|
||||
|
||||
# Write results to worker stdout
|
||||
db.append_worker_stdout(worker_id, json.dumps(results_list, indent=2))
|
||||
else:
|
||||
log("No results found", file=sys.stderr)
|
||||
db.append_worker_stdout(worker_id, json.dumps([], indent=2))
|
||||
|
||||
db.update_worker_status(worker_id, 'completed')
|
||||
return 0
|
||||
|
||||
except Exception as exc:
|
||||
log(f"Search failed: {exc}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
if db:
|
||||
try:
|
||||
db.update_worker_status(worker_id, 'error')
|
||||
except Exception:
|
||||
pass
|
||||
return 1
|
||||
|
||||
finally:
|
||||
# Always close the database connection
|
||||
if db:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
117
cmdlets/search_provider.py
Normal file
117
cmdlets/search_provider.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""search-provider cmdlet: Search external providers (bandcamp, libgen, soulseek, youtube)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Sequence
|
||||
import sys
|
||||
|
||||
from helper.logger import log, debug
|
||||
from helper.provider import get_search_provider, list_search_providers
|
||||
|
||||
from ._shared import Cmdlet, CmdletArg, should_show_help
|
||||
import pipeline as ctx
|
||||
|
||||
|
||||
class Search_Provider(Cmdlet):
|
||||
"""Search external content providers."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="search-provider",
|
||||
summary="Search external providers (bandcamp, libgen, soulseek, youtube)",
|
||||
usage="search-provider <provider> <query> [-limit N]",
|
||||
arg=[
|
||||
CmdletArg("provider", type="string", required=True, description="Provider name: bandcamp, libgen, soulseek, youtube"),
|
||||
CmdletArg("query", type="string", required=True, description="Search query (supports provider-specific syntax)"),
|
||||
CmdletArg("limit", type="int", description="Maximum results to return (default: 50)"),
|
||||
],
|
||||
detail=[
|
||||
"Search external content providers:",
|
||||
"- bandcamp: Search for music albums/tracks",
|
||||
" Example: search-provider bandcamp \"artist:altrusian grace\"",
|
||||
"- libgen: Search Library Genesis for books",
|
||||
" Example: search-provider libgen \"python programming\"",
|
||||
"- soulseek: Search P2P network for music",
|
||||
" Example: search-provider soulseek \"pink floyd\"",
|
||||
"- youtube: Search YouTube for videos",
|
||||
" Example: search-provider youtube \"tutorial\"",
|
||||
"",
|
||||
"Query syntax:",
|
||||
"- bandcamp: Use 'artist:Name' to search by artist",
|
||||
"- libgen: Supports isbn:, author:, title: prefixes",
|
||||
"- soulseek: Plain text search",
|
||||
"- youtube: Plain text search",
|
||||
"",
|
||||
"Results can be piped to other cmdlets:",
|
||||
" search-provider bandcamp \"artist:grace\" | @1 | download-data",
|
||||
],
|
||||
exec=self.run
|
||||
)
|
||||
self.register()
|
||||
|
||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Execute search-provider cmdlet."""
|
||||
if should_show_help(args):
|
||||
ctx.emit(self.__dict__)
|
||||
return 0
|
||||
|
||||
# Parse arguments
|
||||
if len(args) < 2:
|
||||
log("Error: search-provider requires <provider> and <query> arguments", file=sys.stderr)
|
||||
log(f"Usage: {self.usage}", file=sys.stderr)
|
||||
log("Available providers:", file=sys.stderr)
|
||||
providers = list_search_providers(config)
|
||||
for name, available in sorted(providers.items()):
|
||||
status = "✓" if available else "✗"
|
||||
log(f" {status} {name}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
provider_name = args[0]
|
||||
query = args[1]
|
||||
|
||||
# Parse optional limit
|
||||
limit = 50
|
||||
if len(args) >= 4 and args[2] in ("-limit", "--limit"):
|
||||
try:
|
||||
limit = int(args[3])
|
||||
except ValueError:
|
||||
log(f"Warning: Invalid limit value '{args[3]}', using default 50", file=sys.stderr)
|
||||
|
||||
debug(f"[search-provider] provider={provider_name}, query={query}, limit={limit}")
|
||||
|
||||
# Get provider
|
||||
provider = get_search_provider(provider_name, config)
|
||||
if not provider:
|
||||
log(f"Error: Provider '{provider_name}' is not available", file=sys.stderr)
|
||||
log("Available providers:", file=sys.stderr)
|
||||
providers = list_search_providers(config)
|
||||
for name, available in sorted(providers.items()):
|
||||
if available:
|
||||
log(f" - {name}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Execute search
|
||||
try:
|
||||
debug(f"[search-provider] Calling {provider_name}.search()")
|
||||
results = provider.search(query, limit=limit)
|
||||
debug(f"[search-provider] Got {len(results)} results")
|
||||
|
||||
if not results:
|
||||
log(f"No results found for query: {query}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
# Emit results for pipeline
|
||||
for search_result in results:
|
||||
ctx.emit(search_result.to_dict())
|
||||
|
||||
log(f"Found {len(results)} result(s) from {provider_name}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
log(f"Error searching {provider_name}: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
debug(traceback.format_exc())
|
||||
return 1
|
||||
|
||||
|
||||
# Register cmdlet instance
|
||||
Search_Provider_Instance = Search_Provider()
|
||||
341
cmdlets/search_store.py
Normal file
341
cmdlets/search_store.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""Search-store cmdlet: Search for files in storage backends (Folder, Hydrus)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Sequence, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from collections import OrderedDict
|
||||
import re
|
||||
import json
|
||||
import sys
|
||||
|
||||
from helper.logger import log, debug
|
||||
|
||||
from ._shared import Cmdlet, CmdletArg, get_origin, get_field, should_show_help
|
||||
import pipeline as ctx
|
||||
|
||||
# Optional dependencies
|
||||
try:
|
||||
import mutagen # type: ignore
|
||||
except ImportError: # pragma: no cover
|
||||
mutagen = None # type: ignore
|
||||
|
||||
try:
|
||||
from config import get_hydrus_url, resolve_output_dir
|
||||
except Exception: # pragma: no cover
|
||||
get_hydrus_url = None # type: ignore
|
||||
resolve_output_dir = None # type: ignore
|
||||
|
||||
try:
|
||||
from helper.hydrus import HydrusClient, HydrusRequestError
|
||||
except ImportError: # pragma: no cover
|
||||
HydrusClient = None # type: ignore
|
||||
HydrusRequestError = RuntimeError # type: ignore
|
||||
|
||||
try:
|
||||
from helper.utils import sha256_file
|
||||
except ImportError: # pragma: no cover
|
||||
sha256_file = None # type: ignore
|
||||
|
||||
try:
|
||||
from helper.utils_constant import mime_maps
|
||||
except ImportError: # pragma: no cover
|
||||
mime_maps = {} # type: ignore
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SearchRecord:
|
||||
path: str
|
||||
size_bytes: int | None = None
|
||||
duration_seconds: str | None = None
|
||||
tags: str | None = None
|
||||
hash_hex: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
payload: dict[str, str] = {"path": self.path}
|
||||
if self.size_bytes is not None:
|
||||
payload["size"] = str(self.size_bytes)
|
||||
if self.duration_seconds:
|
||||
payload["duration"] = self.duration_seconds
|
||||
if self.tags:
|
||||
payload["tags"] = self.tags
|
||||
if self.hash_hex:
|
||||
payload["hash"] = self.hash_hex
|
||||
return payload
|
||||
|
||||
|
||||
STORAGE_ORIGINS = {"local", "hydrus", "folder"}
|
||||
|
||||
|
||||
class Search_Store(Cmdlet):
|
||||
"""Class-based search-store cmdlet for searching storage backends."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
name="search-store",
|
||||
summary="Search storage backends (Folder, Hydrus) for files.",
|
||||
usage="search-store [query] [-tag TAG] [-size >100MB|<50MB] [-type audio|video|image] [-duration >10:00] [-store BACKEND]",
|
||||
arg=[
|
||||
CmdletArg("query", description="Search query string"),
|
||||
CmdletArg("tag", description="Filter by tag (can be used multiple times)"),
|
||||
CmdletArg("size", description="Filter by size: >100MB, <50MB, =10MB"),
|
||||
CmdletArg("type", description="Filter by type: audio, video, image, document"),
|
||||
CmdletArg("duration", description="Filter by duration: >10:00, <1:30:00"),
|
||||
CmdletArg("limit", type="integer", description="Limit results (default: 100)"),
|
||||
CmdletArg("store", description="Search specific storage backend (e.g., 'home', 'test', or 'default')"),
|
||||
],
|
||||
detail=[
|
||||
"Search across storage backends: Folder stores and Hydrus instances",
|
||||
"Use -store to search a specific backend by name",
|
||||
"Filter results by: tag, size, type, duration",
|
||||
"Results include hash for downstream commands (get-file, add-tag, etc.)",
|
||||
"Examples:",
|
||||
"search-store foo # Search all storage backends",
|
||||
"search-store -store home '*' # Search 'home' Hydrus instance",
|
||||
"search-store -store test 'video' # Search 'test' folder store",
|
||||
"search-store song -type audio # Search for audio files",
|
||||
"search-store movie -tag action # Search with tag filter",
|
||||
],
|
||||
exec=self.run,
|
||||
)
|
||||
self.register()
|
||||
|
||||
# --- Helper methods -------------------------------------------------
|
||||
@staticmethod
|
||||
def _normalize_extension(ext_value: Any) -> str:
|
||||
"""Sanitize extension strings to alphanumerics and cap at 5 chars."""
|
||||
ext = str(ext_value or "").strip().lstrip(".")
|
||||
for sep in (" ", "|", "(", "[", "{", ",", ";"):
|
||||
if sep in ext:
|
||||
ext = ext.split(sep, 1)[0]
|
||||
break
|
||||
if "." in ext:
|
||||
ext = ext.split(".")[-1]
|
||||
ext = "".join(ch for ch in ext if ch.isalnum())
|
||||
return ext[:5]
|
||||
|
||||
def _ensure_storage_columns(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Ensure storage results have the necessary fields for result_table display."""
|
||||
store_value = str(get_origin(payload, "") or "").lower()
|
||||
if store_value not in STORAGE_ORIGINS:
|
||||
return payload
|
||||
|
||||
# Ensure we have title field
|
||||
if "title" not in payload:
|
||||
payload["title"] = payload.get("name") or payload.get("target") or payload.get("path") or "Result"
|
||||
|
||||
# Ensure we have ext field
|
||||
if "ext" not in payload:
|
||||
title = str(payload.get("title", ""))
|
||||
path_obj = Path(title)
|
||||
if path_obj.suffix:
|
||||
payload["ext"] = self._normalize_extension(path_obj.suffix.lstrip('.'))
|
||||
else:
|
||||
payload["ext"] = payload.get("ext", "")
|
||||
|
||||
# Ensure size_bytes is present for display (already set by search_file())
|
||||
# result_table will handle formatting it
|
||||
|
||||
# Don't create manual columns - let result_table handle display
|
||||
# This allows the table to respect max_columns and apply consistent formatting
|
||||
return payload
|
||||
|
||||
# --- Execution ------------------------------------------------------
|
||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Search storage backends for files."""
|
||||
if should_show_help(args):
|
||||
log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}")
|
||||
return 0
|
||||
|
||||
args_list = [str(arg) for arg in (args or [])]
|
||||
|
||||
# Parse arguments
|
||||
query = ""
|
||||
tag_filters: List[str] = []
|
||||
size_filter: Optional[Tuple[str, int]] = None
|
||||
duration_filter: Optional[Tuple[str, float]] = None
|
||||
type_filter: Optional[str] = None
|
||||
storage_backend: Optional[str] = None
|
||||
limit = 100
|
||||
searched_backends: List[str] = []
|
||||
|
||||
i = 0
|
||||
while i < len(args_list):
|
||||
arg = args_list[i]
|
||||
low = arg.lower()
|
||||
if low in {"-store", "--store", "-storage", "--storage"} and i + 1 < len(args_list):
|
||||
storage_backend = args_list[i + 1]
|
||||
i += 2
|
||||
elif low in {"-tag", "--tag"} and i + 1 < len(args_list):
|
||||
tag_filters.append(args_list[i + 1])
|
||||
i += 2
|
||||
elif low in {"-limit", "--limit"} and i + 1 < len(args_list):
|
||||
try:
|
||||
limit = int(args_list[i + 1])
|
||||
except ValueError:
|
||||
limit = 100
|
||||
i += 2
|
||||
elif low in {"-type", "--type"} and i + 1 < len(args_list):
|
||||
type_filter = args_list[i + 1].lower()
|
||||
i += 2
|
||||
elif not arg.startswith("-"):
|
||||
query = f"{query} {arg}".strip() if query else arg
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
store_filter: Optional[str] = None
|
||||
if query:
|
||||
match = re.search(r"\bstore:([^\s,]+)", query, flags=re.IGNORECASE)
|
||||
if match:
|
||||
store_filter = match.group(1).strip() or None
|
||||
query = re.sub(r"\s*[,]?\s*store:[^\s,]+", " ", query, flags=re.IGNORECASE)
|
||||
query = re.sub(r"\s{2,}", " ", query)
|
||||
query = query.strip().strip(',')
|
||||
|
||||
if store_filter and not storage_backend:
|
||||
storage_backend = store_filter
|
||||
|
||||
if not query:
|
||||
log("Provide a search query", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
from helper.folder_store import FolderDB
|
||||
from config import get_local_storage_path
|
||||
import uuid
|
||||
worker_id = str(uuid.uuid4())
|
||||
library_root = get_local_storage_path(config or {})
|
||||
if not library_root:
|
||||
log("No library root configured", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Use context manager to ensure database is always closed
|
||||
with FolderDB(library_root) as db:
|
||||
try:
|
||||
db.insert_worker(
|
||||
worker_id,
|
||||
"search-store",
|
||||
title=f"Search: {query}",
|
||||
description=f"Query: {query}",
|
||||
pipe=ctx.get_current_command_text()
|
||||
)
|
||||
|
||||
results_list = []
|
||||
import result_table
|
||||
import importlib
|
||||
importlib.reload(result_table)
|
||||
from result_table import ResultTable
|
||||
|
||||
table_title = f"Search: {query}"
|
||||
if storage_backend:
|
||||
table_title += f" [{storage_backend}]"
|
||||
|
||||
table = ResultTable(table_title)
|
||||
|
||||
from helper.store import FileStorage
|
||||
storage = FileStorage(config=config or {})
|
||||
|
||||
backend_to_search = storage_backend or None
|
||||
if backend_to_search:
|
||||
searched_backends.append(backend_to_search)
|
||||
target_backend = storage[backend_to_search]
|
||||
if not callable(getattr(target_backend, 'search_file', None)):
|
||||
log(f"Backend '{backend_to_search}' does not support searching", file=sys.stderr)
|
||||
db.update_worker_status(worker_id, 'error')
|
||||
return 1
|
||||
results = target_backend.search_file(query, limit=limit)
|
||||
else:
|
||||
from helper.hydrus import is_hydrus_available
|
||||
hydrus_available = is_hydrus_available(config or {})
|
||||
|
||||
all_results = []
|
||||
for backend_name in storage.list_searchable_backends():
|
||||
if backend_name.startswith("hydrus") and not hydrus_available:
|
||||
continue
|
||||
searched_backends.append(backend_name)
|
||||
try:
|
||||
backend_results = storage[backend_name].search_file(query, limit=limit - len(all_results))
|
||||
if backend_results:
|
||||
all_results.extend(backend_results)
|
||||
if len(all_results) >= limit:
|
||||
break
|
||||
except Exception as exc:
|
||||
log(f"Backend {backend_name} search failed: {exc}", file=sys.stderr)
|
||||
results = all_results[:limit]
|
||||
|
||||
def _format_storage_label(name: str) -> str:
|
||||
clean = str(name or "").strip()
|
||||
if not clean:
|
||||
return "Unknown"
|
||||
return clean.replace("_", " ").title()
|
||||
|
||||
storage_counts: OrderedDict[str, int] = OrderedDict((name, 0) for name in searched_backends)
|
||||
for item in results or []:
|
||||
origin = get_origin(item)
|
||||
if not origin:
|
||||
continue
|
||||
key = str(origin).lower()
|
||||
if key not in storage_counts:
|
||||
storage_counts[key] = 0
|
||||
storage_counts[key] += 1
|
||||
|
||||
if storage_counts or query:
|
||||
display_counts = OrderedDict((_format_storage_label(name), count) for name, count in storage_counts.items())
|
||||
summary_line = table.set_storage_summary(display_counts, query, inline=True)
|
||||
if summary_line:
|
||||
table.title = summary_line
|
||||
|
||||
if results:
|
||||
for item in results:
|
||||
def _as_dict(obj: Any) -> Dict[str, Any]:
|
||||
if isinstance(obj, dict):
|
||||
return dict(obj)
|
||||
if hasattr(obj, "to_dict") and callable(getattr(obj, "to_dict")):
|
||||
return obj.to_dict() # type: ignore[arg-type]
|
||||
return {"title": str(obj)}
|
||||
|
||||
item_dict = _as_dict(item)
|
||||
if store_filter:
|
||||
origin_val = str(get_origin(item_dict) or "").lower()
|
||||
if store_filter != origin_val:
|
||||
continue
|
||||
normalized = self._ensure_storage_columns(item_dict)
|
||||
|
||||
# Make hash/store available for downstream cmdlets without rerunning search
|
||||
hash_val = normalized.get("hash")
|
||||
store_val = normalized.get("store") or get_origin(item_dict)
|
||||
if hash_val and not normalized.get("hash"):
|
||||
normalized["hash"] = hash_val
|
||||
if store_val and not normalized.get("store"):
|
||||
normalized["store"] = store_val
|
||||
|
||||
table.add_result(normalized)
|
||||
|
||||
results_list.append(normalized)
|
||||
ctx.emit(normalized)
|
||||
|
||||
# Debug: Verify table rows match items list
|
||||
debug(f"[search-store] Added {len(table.rows)} rows to table, {len(results_list)} items to results_list")
|
||||
if len(table.rows) != len(results_list):
|
||||
debug(f"[search-store] WARNING: Table/items mismatch! rows={len(table.rows)} items={len(results_list)}", file=sys.stderr)
|
||||
|
||||
ctx.set_last_result_table(table, results_list)
|
||||
db.append_worker_stdout(worker_id, json.dumps(results_list, indent=2))
|
||||
else:
|
||||
log("No results found", file=sys.stderr)
|
||||
db.append_worker_stdout(worker_id, json.dumps([], indent=2))
|
||||
|
||||
db.update_worker_status(worker_id, 'completed')
|
||||
return 0
|
||||
|
||||
except Exception as exc:
|
||||
log(f"Search failed: {exc}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
try:
|
||||
db.update_worker_status(worker_id, 'error')
|
||||
except Exception:
|
||||
pass
|
||||
return 1
|
||||
|
||||
|
||||
CMDLET = Search_Store()
|
||||
@@ -26,12 +26,12 @@ CMDLET = Cmdlet(
|
||||
name="trim-file",
|
||||
summary="Trim a media file using ffmpeg.",
|
||||
usage="trim-file [-path <path>] -range <start-end> [-delete]",
|
||||
args=[
|
||||
arg=[
|
||||
CmdletArg("-path", description="Path to the file (optional if piped)."),
|
||||
CmdletArg("-range", required=True, description="Time range to trim (e.g. '3:45-3:55' or '00:03:45-00:03:55')."),
|
||||
CmdletArg("-delete", type="flag", description="Delete the original file after trimming."),
|
||||
],
|
||||
details=[
|
||||
detail=[
|
||||
"Creates a new file with 'clip_' prefix in the filename/title.",
|
||||
"Inherits tags from the source file.",
|
||||
"Adds a relationship to the source file (if hash is available).",
|
||||
@@ -133,7 +133,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
# If path arg provided, add it to inputs
|
||||
if path_arg:
|
||||
inputs.append({"file_path": path_arg})
|
||||
inputs.append({"path": path_arg})
|
||||
|
||||
if not inputs:
|
||||
log("No input files provided.", file=sys.stderr)
|
||||
@@ -145,9 +145,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Resolve file path
|
||||
file_path = None
|
||||
if isinstance(item, dict):
|
||||
file_path = item.get("file_path") or item.get("path") or item.get("target")
|
||||
elif hasattr(item, "file_path"):
|
||||
file_path = item.file_path
|
||||
file_path = item.get("path") or item.get("target")
|
||||
elif hasattr(item, "path"):
|
||||
file_path = item.path
|
||||
elif isinstance(item, str):
|
||||
file_path = item
|
||||
|
||||
@@ -175,9 +175,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# 1. Get source hash for relationship
|
||||
source_hash = None
|
||||
if isinstance(item, dict):
|
||||
source_hash = item.get("hash") or item.get("file_hash")
|
||||
elif hasattr(item, "file_hash"):
|
||||
source_hash = item.file_hash
|
||||
source_hash = item.get("hash")
|
||||
elif hasattr(item, "hash"):
|
||||
source_hash = item.hash
|
||||
|
||||
if not source_hash:
|
||||
try:
|
||||
@@ -219,18 +219,18 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Update original file in local DB if possible
|
||||
try:
|
||||
from config import get_local_storage_path
|
||||
from helper.local_library import LocalLibraryDB
|
||||
from helper.folder_store import FolderDB
|
||||
|
||||
storage_path = get_local_storage_path(config)
|
||||
if storage_path:
|
||||
with LocalLibraryDB(storage_path) as db:
|
||||
with FolderDB(storage_path) as db:
|
||||
# Get original file metadata
|
||||
# We need to find the original file by hash or path
|
||||
# Try path first
|
||||
orig_meta = db.get_metadata(path_obj)
|
||||
if not orig_meta and source_hash:
|
||||
# Try by hash
|
||||
orig_path_resolved = db.search_by_hash(source_hash)
|
||||
orig_path_resolved = db.search_hash(source_hash)
|
||||
if orig_path_resolved:
|
||||
orig_meta = db.get_metadata(orig_path_resolved)
|
||||
|
||||
@@ -256,7 +256,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
orig_meta["hash"] = source_hash
|
||||
|
||||
# We need the path to save
|
||||
save_path = Path(orig_meta.get("file_path") or path_obj)
|
||||
save_path = Path(orig_meta.get("path") or path_obj)
|
||||
db.save_metadata(save_path, orig_meta)
|
||||
log(f"Updated relationship for original file: {save_path.name}", file=sys.stderr)
|
||||
except Exception as e:
|
||||
@@ -264,7 +264,6 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
# 5. Construct result
|
||||
result_dict = {
|
||||
"file_path": str(output_path),
|
||||
"path": str(output_path),
|
||||
"title": new_title,
|
||||
"tags": new_tags,
|
||||
|
||||
Reference in New Issue
Block a user