f
This commit is contained in:
@@ -907,10 +907,13 @@ class HydrusNetwork:
|
||||
file_ids: Sequence[int] | None = None,
|
||||
hashes: Sequence[str] | None = None,
|
||||
include_service_keys_to_tags: bool = True,
|
||||
include_tag_services: bool = False,
|
||||
include_file_services: bool = False,
|
||||
include_file_url: bool = False,
|
||||
include_duration: bool = True,
|
||||
include_size: bool = True,
|
||||
include_mime: bool = False,
|
||||
include_is_trashed: bool = False,
|
||||
include_notes: bool = False,
|
||||
) -> dict[str,
|
||||
Any]:
|
||||
@@ -929,6 +932,16 @@ class HydrusNetwork:
|
||||
include_service_keys_to_tags,
|
||||
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, lambda v: "true" if v else None),
|
||||
("include_duration",
|
||||
@@ -937,6 +950,11 @@ class HydrusNetwork:
|
||||
include_size, lambda v: "true" if v else None),
|
||||
("include_mime",
|
||||
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, lambda v: "true" if v else None),
|
||||
]
|
||||
@@ -1831,6 +1849,48 @@ def get_tag_service_key(client: HydrusNetwork,
|
||||
if not isinstance(services, dict):
|
||||
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
|
||||
for group in services.values():
|
||||
if not isinstance(group, list):
|
||||
@@ -1838,10 +1898,13 @@ def get_tag_service_key(client: HydrusNetwork,
|
||||
for item in group:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name") or "").strip().lower()
|
||||
key = item.get("service_key") or item.get("key")
|
||||
if name == fallback_name.lower() and key:
|
||||
return str(key)
|
||||
name = _normalize_name(item.get("name"))
|
||||
if name != target_name:
|
||||
continue
|
||||
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
|
||||
|
||||
|
||||
@@ -2234,6 +2234,25 @@ class PipelineExecutor:
|
||||
|
||||
# After inserting/appending an auto-stage, continue processing so later
|
||||
# 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
|
||||
else:
|
||||
debug(f"@N: No items to select from (items_list empty)")
|
||||
|
||||
@@ -364,6 +364,18 @@ class HydrusNetwork(Store):
|
||||
except Exception:
|
||||
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:
|
||||
"""Upload file to Hydrus with full metadata support.
|
||||
|
||||
@@ -411,25 +423,50 @@ class HydrusNetwork(Store):
|
||||
if client is None:
|
||||
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
|
||||
try:
|
||||
metadata = client.fetch_file_metadata(
|
||||
hashes=[file_hash],
|
||||
include_service_keys_to_tags=False,
|
||||
include_file_services=True,
|
||||
include_is_trashed=True,
|
||||
include_file_url=True,
|
||||
include_duration=False,
|
||||
include_size=False,
|
||||
include_mime=False,
|
||||
include_size=True,
|
||||
include_mime=True,
|
||||
)
|
||||
if metadata and isinstance(metadata, dict):
|
||||
metas = metadata.get("metadata", [])
|
||||
if isinstance(metas, list) and metas:
|
||||
# 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:
|
||||
if isinstance(meta,
|
||||
dict) and meta.get("file_id") is not None:
|
||||
if not isinstance(meta, dict):
|
||||
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
|
||||
break
|
||||
if file_exists:
|
||||
@@ -440,13 +477,54 @@ class HydrusNetwork(Store):
|
||||
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'.
|
||||
# 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:
|
||||
try:
|
||||
client.undelete_files([file_hash])
|
||||
except Exception:
|
||||
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
|
||||
if not file_exists:
|
||||
debug(
|
||||
@@ -1199,48 +1277,15 @@ class HydrusNetwork(Store):
|
||||
|
||||
file_id = meta.get("file_id")
|
||||
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",
|
||||
{})
|
||||
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)))
|
||||
title, all_tags = self._extract_title_and_tags(meta, file_id)
|
||||
|
||||
# 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 []
|
||||
@@ -1340,55 +1385,15 @@ class HydrusNetwork(Store):
|
||||
|
||||
file_id = meta.get("file_id")
|
||||
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
|
||||
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)))
|
||||
title, all_tags = self._extract_title_and_tags(meta, file_id)
|
||||
|
||||
# Prefer Hydrus-provided extension (e.g. ".webm"); fall back to MIME map.
|
||||
mime_type = meta.get("mime")
|
||||
@@ -1694,21 +1699,19 @@ class HydrusNetwork(Store):
|
||||
|
||||
# Extract title from tags
|
||||
title = f"Hydrus_{file_hash[:12]}"
|
||||
tags_payload = meta.get("tags",
|
||||
{})
|
||||
if isinstance(tags_payload, dict):
|
||||
for service_data in tags_payload.values():
|
||||
if isinstance(service_data, dict):
|
||||
display_tags = service_data.get("display_tags",
|
||||
{})
|
||||
if isinstance(display_tags, dict):
|
||||
current_tags = display_tags.get("0", [])
|
||||
if isinstance(current_tags, list):
|
||||
for tag in current_tags:
|
||||
if str(tag).lower().startswith("title:"):
|
||||
title = tag.split(":", 1)[1].strip()
|
||||
break
|
||||
if title != f"Hydrus_{file_hash[:12]}":
|
||||
extracted_tags = self._extract_tags_from_hydrus_meta(
|
||||
meta,
|
||||
service_key=None,
|
||||
service_name="my tags",
|
||||
)
|
||||
for raw_tag in extracted_tags:
|
||||
tag_text = str(raw_tag or "").strip()
|
||||
if not tag_text:
|
||||
continue
|
||||
if tag_text.lower().startswith("title:"):
|
||||
value = tag_text.split(":", 1)[1].strip()
|
||||
if value:
|
||||
title = value
|
||||
break
|
||||
|
||||
# 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:
|
||||
size_val = meta.get("size_bytes")
|
||||
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:
|
||||
size_int = None
|
||||
size_int = 0
|
||||
|
||||
dur_val = meta.get("duration")
|
||||
if dur_val is None:
|
||||
@@ -2157,7 +2160,7 @@ class HydrusNetwork(Store):
|
||||
try:
|
||||
if service_key:
|
||||
# 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
|
||||
continue
|
||||
except Exception as exc:
|
||||
@@ -2295,28 +2298,128 @@ class HydrusNetwork(Store):
|
||||
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 []
|
||||
desired_service_name = str(service_name or "").strip().lower()
|
||||
desired_service_key = str(service_key).strip() if service_key is not None else ""
|
||||
|
||||
# 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()
|
||||
]
|
||||
def _append_tag(out: List[str], value: Any) -> None:
|
||||
text = ""
|
||||
if isinstance(value, bytes):
|
||||
try:
|
||||
text = value.decode("utf-8", errors="ignore")
|
||||
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)
|
||||
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()
|
||||
]
|
||||
def _collect_current(container: Any, out: List[str]) -> None:
|
||||
if isinstance(container, list):
|
||||
for tag in container:
|
||||
_append_tag(out, tag)
|
||||
return
|
||||
if isinstance(container, dict):
|
||||
current = container.get("0")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user