This commit is contained in:
2026-02-11 20:25:22 -08:00
parent ba623cb992
commit 6416aeceec
4 changed files with 327 additions and 142 deletions

View File

@@ -907,10 +907,13 @@ class HydrusNetwork:
file_ids: Sequence[int] | None = None, file_ids: Sequence[int] | None = None,
hashes: Sequence[str] | None = None, hashes: Sequence[str] | None = None,
include_service_keys_to_tags: bool = True, include_service_keys_to_tags: bool = True,
include_tag_services: bool = False,
include_file_services: bool = False,
include_file_url: bool = False, include_file_url: bool = False,
include_duration: bool = True, include_duration: bool = True,
include_size: bool = True, include_size: bool = True,
include_mime: bool = False, include_mime: bool = False,
include_is_trashed: bool = False,
include_notes: bool = False, include_notes: bool = False,
) -> dict[str, ) -> dict[str,
Any]: Any]:
@@ -929,6 +932,16 @@ class HydrusNetwork:
include_service_keys_to_tags, include_service_keys_to_tags,
lambda v: "true" if v else None, lambda v: "true" if v else None,
), ),
(
"include_tag_services",
include_tag_services,
lambda v: "true" if v else None,
),
(
"include_file_services",
include_file_services,
lambda v: "true" if v else None,
),
("include_file_url", ("include_file_url",
include_file_url, lambda v: "true" if v else None), include_file_url, lambda v: "true" if v else None),
("include_duration", ("include_duration",
@@ -937,6 +950,11 @@ class HydrusNetwork:
include_size, lambda v: "true" if v else None), include_size, lambda v: "true" if v else None),
("include_mime", ("include_mime",
include_mime, lambda v: "true" if v else None), include_mime, lambda v: "true" if v else None),
(
"include_is_trashed",
include_is_trashed,
lambda v: "true" if v else None,
),
("include_notes", ("include_notes",
include_notes, lambda v: "true" if v else None), include_notes, lambda v: "true" if v else None),
] ]
@@ -1831,6 +1849,48 @@ def get_tag_service_key(client: HydrusNetwork,
if not isinstance(services, dict): if not isinstance(services, dict):
return None return None
def _normalize_name(value: Any) -> str:
if isinstance(value, bytes):
try:
return value.decode("utf-8", errors="ignore").strip().lower()
except Exception:
return ""
try:
return str(value or "").strip().lower()
except Exception:
return ""
def _normalize_service_key(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, bytes):
# Hydrus service keys are raw bytes; API expects hex.
try:
hex_text = value.hex()
except Exception:
return None
return hex_text if (len(hex_text) == 64 and all(ch in "0123456789abcdef" for ch in hex_text)) else None
if isinstance(value, str):
text = value.strip().lower()
if text.startswith("0x"):
text = text[2:]
if len(text) == 64 and all(ch in "0123456789abcdef" for ch in text):
return text
return None
try:
text = str(value).strip().lower()
except Exception:
return None
if text.startswith("0x"):
text = text[2:]
if len(text) == 64 and all(ch in "0123456789abcdef" for ch in text):
return text
return None
target_name = _normalize_name(fallback_name)
if not target_name:
target_name = "my tags"
# Hydrus returns services grouped by type; walk all lists and match on name # Hydrus returns services grouped by type; walk all lists and match on name
for group in services.values(): for group in services.values():
if not isinstance(group, list): if not isinstance(group, list):
@@ -1838,10 +1898,13 @@ def get_tag_service_key(client: HydrusNetwork,
for item in group: for item in group:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
name = str(item.get("name") or "").strip().lower() name = _normalize_name(item.get("name"))
key = item.get("service_key") or item.get("key") if name != target_name:
if name == fallback_name.lower() and key: continue
return str(key) key_raw = item.get("service_key") or item.get("key")
normalized_key = _normalize_service_key(key_raw)
if normalized_key:
return normalized_key
return None return None

View File

@@ -2234,6 +2234,25 @@ class PipelineExecutor:
# After inserting/appending an auto-stage, continue processing so later # After inserting/appending an auto-stage, continue processing so later
# selection-expansion logic can still run (e.g., for example selectors). # selection-expansion logic can still run (e.g., for example selectors).
if (not stages) and selection_indices and len(selection_indices) == 1:
# Selection-only invocation (e.g. user types @1 with no pipe).
# Show the item details panel so selection feels actionable.
try:
selected_item = filtered[0] if filtered else None
if selected_item is not None and not isinstance(selected_item, dict):
to_dict = getattr(selected_item, "to_dict", None)
if callable(to_dict):
selected_item = to_dict()
if isinstance(selected_item, dict):
from SYS.rich_display import render_item_details_panel
render_item_details_panel(selected_item)
try:
ctx.set_last_result_items_only([selected_item])
except Exception:
pass
except Exception:
logger.exception("Failed to render selection-only item details")
return True, piped_result return True, piped_result
else: else:
debug(f"@N: No items to select from (items_list empty)") debug(f"@N: No items to select from (items_list empty)")

View File

@@ -364,6 +364,18 @@ class HydrusNetwork(Store):
except Exception: except Exception:
return False return False
@staticmethod
def _has_current_file_service(meta: Dict[str, Any]) -> bool:
services = meta.get("file_services")
if not isinstance(services, dict):
return False
current = services.get("current")
if isinstance(current, dict):
return any(bool(v) for v in current.values())
if isinstance(current, list):
return len(current) > 0
return False
def add_file(self, file_path: Path, **kwargs: Any) -> str: def add_file(self, file_path: Path, **kwargs: Any) -> str:
"""Upload file to Hydrus with full metadata support. """Upload file to Hydrus with full metadata support.
@@ -411,25 +423,50 @@ class HydrusNetwork(Store):
if client is None: if client is None:
raise Exception("Hydrus client unavailable") raise Exception("Hydrus client unavailable")
# Check if file already exists in Hydrus # Check if file already exists in Hydrus.
# IMPORTANT: some Hydrus deployments can return a metadata record (file_id)
# even when the file is not in any current file service (e.g. trashed/missing).
# Only treat as a real duplicate if it is in a current file service.
file_exists = False file_exists = False
try: try:
metadata = client.fetch_file_metadata( metadata = client.fetch_file_metadata(
hashes=[file_hash], hashes=[file_hash],
include_service_keys_to_tags=False, include_service_keys_to_tags=False,
include_file_services=True,
include_is_trashed=True,
include_file_url=True, include_file_url=True,
include_duration=False, include_duration=False,
include_size=False, include_size=True,
include_mime=False, include_mime=True,
) )
if metadata and isinstance(metadata, dict): if metadata and isinstance(metadata, dict):
metas = metadata.get("metadata", []) metas = metadata.get("metadata", [])
if isinstance(metas, list) and metas: if isinstance(metas, list) and metas:
# Hydrus returns placeholder rows for unknown hashes. # Hydrus returns placeholder rows for unknown hashes.
# Only treat as a real duplicate if it has a concrete file_id. # Only treat as a real duplicate if it has a concrete file_id AND
# appears in a current file service.
for meta in metas: for meta in metas:
if isinstance(meta, if not isinstance(meta, dict):
dict) and meta.get("file_id") is not None: continue
if meta.get("file_id") is None:
continue
# Preferred: use file_services.current.
if isinstance(meta.get("file_services"), dict):
if self._has_current_file_service(meta):
file_exists = True
break
continue
# Fallback: if Hydrus doesn't return file_services, only treat as
# existing when the metadata looks like a real file (non-zero size).
size_val = meta.get("size")
if size_val is None:
size_val = meta.get("size_bytes")
try:
size_int = int(size_val) if size_val is not None else 0
except Exception:
size_int = 0
if size_int > 0:
file_exists = True file_exists = True
break break
if file_exists: if file_exists:
@@ -440,13 +477,54 @@ class HydrusNetwork(Store):
debug(f"{self._log_prefix()} metadata fetch failed: {exc}") debug(f"{self._log_prefix()} metadata fetch failed: {exc}")
# If Hydrus reports an existing file, it may be in trash. Best-effort restore it to 'my files'. # If Hydrus reports an existing file, it may be in trash. Best-effort restore it to 'my files'.
# This keeps behavior aligned with user expectation: "use API only" and ensure it lands in my files. # Then re-check that it is actually in a current file service; if not, we'll proceed to upload.
if file_exists: if file_exists:
try: try:
client.undelete_files([file_hash]) client.undelete_files([file_hash])
except Exception: except Exception:
pass pass
try:
metadata2 = client.fetch_file_metadata(
hashes=[file_hash],
include_service_keys_to_tags=False,
include_file_services=True,
include_is_trashed=True,
include_file_url=False,
include_duration=False,
include_size=False,
include_mime=False,
)
metas2 = metadata2.get("metadata", []) if isinstance(metadata2, dict) else []
if isinstance(metas2, list) and metas2:
still_current = False
for meta in metas2:
if not isinstance(meta, dict):
continue
if meta.get("file_id") is None:
continue
if isinstance(meta.get("file_services"), dict):
if self._has_current_file_service(meta):
still_current = True
break
continue
size_val = meta.get("size")
if size_val is None:
size_val = meta.get("size_bytes")
try:
size_int = int(size_val) if size_val is not None else 0
except Exception:
size_int = 0
if size_int > 0:
still_current = True
break
if not still_current:
file_exists = False
except Exception:
# If re-check fails, keep prior behavior (avoid forcing uploads in unknown states)
pass
# Upload file if not already present # Upload file if not already present
if not file_exists: if not file_exists:
debug( debug(
@@ -1199,48 +1277,15 @@ class HydrusNetwork(Store):
file_id = meta.get("file_id") file_id = meta.get("file_id")
hash_hex = meta.get("hash") hash_hex = meta.get("hash")
size = meta.get("size", 0) size_val = meta.get("size")
if size_val is None:
size_val = meta.get("size_bytes")
try:
size = int(size_val) if size_val is not None else 0
except Exception:
size = 0
tags_set = meta.get("tags", title, all_tags = self._extract_title_and_tags(meta, file_id)
{})
all_tags: list[str] = []
title = f"Hydrus File {file_id}"
if isinstance(tags_set, dict):
def _collect(tag_list: Any) -> None:
nonlocal title
if not isinstance(tag_list, list):
return
for tag in tag_list:
tag_text = str(tag) if tag else ""
if not tag_text:
continue
tag_l = tag_text.strip().lower()
if not tag_l:
continue
all_tags.append(tag_l)
if (tag_l.startswith("title:") and title
== f"Hydrus File {file_id}"):
title = tag_l.split(":", 1)[1].strip()
for _service_name, service_tags in tags_set.items():
if not isinstance(service_tags, dict):
continue
storage_tags = service_tags.get(
"storage_tags",
{}
)
if isinstance(storage_tags, dict):
for tag_list in storage_tags.values():
_collect(tag_list)
display_tags = service_tags.get(
"display_tags",
[]
)
_collect(display_tags)
# Unique tags
all_tags = sorted(list(set(all_tags)))
# Use known URLs (source URLs) from Hydrus if available (matches get-url cmdlet) # Use known URLs (source URLs) from Hydrus if available (matches get-url cmdlet)
item_url = meta.get("known_urls") or meta.get("urls") or meta.get("url") or [] item_url = meta.get("known_urls") or meta.get("urls") or meta.get("url") or []
@@ -1340,55 +1385,15 @@ class HydrusNetwork(Store):
file_id = meta.get("file_id") file_id = meta.get("file_id")
hash_hex = meta.get("hash") hash_hex = meta.get("hash")
size = meta.get("size", 0) size_val = meta.get("size")
if size_val is None:
size_val = meta.get("size_bytes")
try:
size = int(size_val) if size_val is not None else 0
except Exception:
size = 0
# Get tags for this file and extract title title, all_tags = self._extract_title_and_tags(meta, file_id)
tags_set = meta.get("tags",
{})
all_tags = []
title = f"Hydrus File {file_id}" # Default fallback
all_tags_str = "" # For substring matching
# debug(f"[HydrusBackend.search] Processing file_id={file_id}, tags type={type(tags_set)}")
if isinstance(tags_set, dict):
# Collect both storage_tags and display_tags to capture siblings/parents and ensure title: is seen
def _collect(tag_list: Any) -> None:
nonlocal title, all_tags_str
if not isinstance(tag_list, list):
return
for tag in tag_list:
tag_text = str(tag) if tag else ""
if not tag_text:
continue
tag_l = tag_text.strip().lower()
if not tag_l:
continue
all_tags.append(tag_l)
all_tags_str += " " + tag_l
if tag_l.startswith("title:"
) and title == f"Hydrus File {file_id}":
title = tag_l.split(":", 1)[1].strip()
for _service_name, service_tags in tags_set.items():
if not isinstance(service_tags, dict):
continue
storage_tags = service_tags.get("storage_tags",
{})
if isinstance(storage_tags, dict):
for tag_list in storage_tags.values():
_collect(tag_list)
display_tags = service_tags.get("display_tags", [])
_collect(display_tags)
# Also consider top-level flattened tags payload if provided (Hydrus API sometimes includes it)
top_level_tags = meta.get("tags_flat", []) or meta.get("tags", [])
_collect(top_level_tags)
# Unique tags
all_tags = sorted(list(set(all_tags)))
# Prefer Hydrus-provided extension (e.g. ".webm"); fall back to MIME map. # Prefer Hydrus-provided extension (e.g. ".webm"); fall back to MIME map.
mime_type = meta.get("mime") mime_type = meta.get("mime")
@@ -1694,21 +1699,19 @@ class HydrusNetwork(Store):
# Extract title from tags # Extract title from tags
title = f"Hydrus_{file_hash[:12]}" title = f"Hydrus_{file_hash[:12]}"
tags_payload = meta.get("tags", extracted_tags = self._extract_tags_from_hydrus_meta(
{}) meta,
if isinstance(tags_payload, dict): service_key=None,
for service_data in tags_payload.values(): service_name="my tags",
if isinstance(service_data, dict): )
display_tags = service_data.get("display_tags", for raw_tag in extracted_tags:
{}) tag_text = str(raw_tag or "").strip()
if isinstance(display_tags, dict): if not tag_text:
current_tags = display_tags.get("0", []) continue
if isinstance(current_tags, list): if tag_text.lower().startswith("title:"):
for tag in current_tags: value = tag_text.split(":", 1)[1].strip()
if str(tag).lower().startswith("title:"): if value:
title = tag.split(":", 1)[1].strip() title = value
break
if title != f"Hydrus_{file_hash[:12]}":
break break
# Hydrus may return mime as an int enum, or sometimes a human label. # Hydrus may return mime as an int enum, or sometimes a human label.
@@ -1777,9 +1780,9 @@ class HydrusNetwork(Store):
if size_val is None: if size_val is None:
size_val = meta.get("size_bytes") size_val = meta.get("size_bytes")
try: try:
size_int: int | None = int(size_val) if size_val is not None else None size_int: int | None = int(size_val) if size_val is not None else 0
except Exception: except Exception:
size_int = None size_int = 0
dur_val = meta.get("duration") dur_val = meta.get("duration")
if dur_val is None: if dur_val is None:
@@ -2157,7 +2160,7 @@ class HydrusNetwork(Store):
try: try:
if service_key: if service_key:
# Mutate tags for many hashes in a single request # Mutate tags for many hashes in a single request
client.mutate_tags_by_key(hashes=hashes, service_key=service_key, add_tags=list(tag_tuple)) client.mutate_tags_by_key(hash=hashes, service_key=service_key, add_tags=list(tag_tuple))
any_success = True any_success = True
continue continue
except Exception as exc: except Exception as exc:
@@ -2295,28 +2298,128 @@ class HydrusNetwork(Store):
if not isinstance(tags_payload, dict): if not isinstance(tags_payload, dict):
return [] return []
svc_data = None desired_service_name = str(service_name or "").strip().lower()
if service_key: desired_service_key = str(service_key).strip() if service_key is not None else ""
svc_data = tags_payload.get(service_key)
if not isinstance(svc_data, dict):
return []
# Prefer display_tags (Hydrus computes siblings/parents) def _append_tag(out: List[str], value: Any) -> None:
display = svc_data.get("display_tags") text = ""
if isinstance(display, list) and display: if isinstance(value, bytes):
return [ try:
str(t) for t in display text = value.decode("utf-8", errors="ignore")
if isinstance(t, (str, bytes)) and str(t).strip() except Exception:
] text = str(value)
elif isinstance(value, str):
text = value
if not text:
return
cleaned = text.strip()
if cleaned:
out.append(cleaned)
# Fallback to storage_tags status '0' (current) def _collect_current(container: Any, out: List[str]) -> None:
storage = svc_data.get("storage_tags") if isinstance(container, list):
if isinstance(storage, dict): for tag in container:
current_list = storage.get("0") or storage.get(0) _append_tag(out, tag)
if isinstance(current_list, list): return
return [ if isinstance(container, dict):
str(t) for t in current_list current = container.get("0")
if isinstance(t, (str, bytes)) and str(t).strip() if current is None:
] current = container.get(0)
if isinstance(current, list):
for tag in current:
_append_tag(out, tag)
return [] def _collect_service_data(service_data: Any, out: List[str]) -> None:
if not isinstance(service_data, dict):
return
display = (
service_data.get("display_tags")
or service_data.get("display_friendly_tags")
or service_data.get("display")
)
_collect_current(display, out)
storage = (
service_data.get("storage_tags")
or service_data.get("statuses_to_tags")
or service_data.get("tags")
)
_collect_current(storage, out)
collected: List[str] = []
if desired_service_key:
_collect_service_data(tags_payload.get(desired_service_key), collected)
if not collected and desired_service_name:
for maybe_service in tags_payload.values():
if not isinstance(maybe_service, dict):
continue
svc_name = str(
maybe_service.get("service_name")
or maybe_service.get("name")
or ""
).strip().lower()
if svc_name and svc_name == desired_service_name:
_collect_service_data(maybe_service, collected)
names_map = tags_payload.get("service_keys_to_names")
statuses_map = tags_payload.get("service_keys_to_statuses_to_tags")
if isinstance(statuses_map, dict):
keys_to_collect: List[str] = []
if desired_service_key:
keys_to_collect.append(desired_service_key)
if desired_service_name and isinstance(names_map, dict):
for raw_key, raw_name in names_map.items():
if str(raw_name or "").strip().lower() == desired_service_name:
keys_to_collect.append(str(raw_key))
keys_filter = {k for k in keys_to_collect if k}
for raw_key, status_payload in statuses_map.items():
raw_key_text = str(raw_key)
if keys_filter and raw_key_text not in keys_filter:
continue
_collect_current(status_payload, collected)
if not collected:
for maybe_service in tags_payload.values():
_collect_service_data(maybe_service, collected)
top_level_tags = meta.get("tags_flat")
if isinstance(top_level_tags, list):
_collect_current(top_level_tags, collected)
deduped: List[str] = []
seen: set[str] = set()
for tag in collected:
key = str(tag).strip().lower()
if not key or key in seen:
continue
seen.add(key)
deduped.append(tag)
return deduped
@staticmethod
def _extract_title_and_tags(meta: Dict[str, Any], file_id: Any) -> Tuple[str, List[str]]:
title = f"Hydrus File {file_id}"
tags = HydrusNetwork._extract_tags_from_hydrus_meta(
meta,
service_key=None,
service_name="my tags",
)
normalized_tags: List[str] = []
seen: set[str] = set()
for raw_tag in tags:
text = str(raw_tag or "").strip().lower()
if not text or text in seen:
continue
seen.add(text)
normalized_tags.append(text)
if text.startswith("title:") and title == f"Hydrus File {file_id}":
value = text.split(":", 1)[1].strip()
if value:
title = value
return title, normalized_tags

View File

@@ -585,11 +585,11 @@ def parse_cmdlet_args(args: Sequence[str],
result = parse_cmdlet_args(["value1", "-count", "5"], cmdlet) result = parse_cmdlet_args(["value1", "-count", "5"], cmdlet)
# result = {"path": "value1", "count": "5"} # result = {"path": "value1", "count": "5"}
""" """
try: try:
from SYS.cmdlet_spec import parse_cmdlet_args as _parse_cmdlet_args_fast from SYS.cmdlet_spec import parse_cmdlet_args as _parse_cmdlet_args_fast
return _parse_cmdlet_args_fast(args, cmdlet_spec) return _parse_cmdlet_args_fast(args, cmdlet_spec)
except Exception: except Exception:
# Fall back to local implementation below to preserve behavior if the # Fall back to local implementation below to preserve behavior if the
# lightweight parser is unavailable. # lightweight parser is unavailable.
pass pass