diff --git a/API/HydrusNetwork.py b/API/HydrusNetwork.py index f184214..a5c2c07 100644 --- a/API/HydrusNetwork.py +++ b/API/HydrusNetwork.py @@ -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 diff --git a/SYS/pipeline.py b/SYS/pipeline.py index d22f148..1610d0b 100644 --- a/SYS/pipeline.py +++ b/SYS/pipeline.py @@ -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)") diff --git a/Store/HydrusNetwork.py b/Store/HydrusNetwork.py index 7876342..2230500 100644 --- a/Store/HydrusNetwork.py +++ b/Store/HydrusNetwork.py @@ -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 diff --git a/cmdlet/_shared.py b/cmdlet/_shared.py index f7645b4..a43f149 100644 --- a/cmdlet/_shared.py +++ b/cmdlet/_shared.py @@ -585,11 +585,11 @@ def parse_cmdlet_args(args: Sequence[str], result = parse_cmdlet_args(["value1", "-count", "5"], cmdlet) # result = {"path": "value1", "count": "5"} """ - try: + try: from SYS.cmdlet_spec import parse_cmdlet_args as _parse_cmdlet_args_fast return _parse_cmdlet_args_fast(args, cmdlet_spec) - except Exception: + except Exception: # Fall back to local implementation below to preserve behavior if the # lightweight parser is unavailable. pass