This commit is contained in:
2026-01-11 02:26:39 -08:00
parent 450a923273
commit 086793790d
4 changed files with 313 additions and 21 deletions

95
CLI.py
View File

@@ -33,7 +33,13 @@ from rich.panel import Panel
from rich.markdown import Markdown
from rich.bar import Bar
from rich.table import Table
from SYS.rich_display import stderr_console, stdout_console
from SYS.rich_display import (
IMAGE_EXTENSIONS,
render_image_to_console,
render_item_details_panel,
stderr_console,
stdout_console,
)
def _install_rich_traceback(*, show_locals: bool = False) -> None:
@@ -4138,6 +4144,93 @@ class PipelineExecutor:
ctx.set_last_result_items_only(items)
return
# Special-case: selecting a single image should show it directly.
if len(items) == 1:
item = items[0]
# Try to get hash and store to resolve through the backend
file_hash = None
store_name = None
if isinstance(item, dict):
file_hash = item.get("hash")
store_name = item.get("store")
else:
if hasattr(item, "hash"):
file_hash = getattr(item, "hash", None)
if hasattr(item, "store"):
store_name = getattr(item, "store", None)
# Try to resolve the file through the Store backend if we have hash + store
resolved_file_path = None
if file_hash and store_name:
try:
from Store import Store
storage = Store(config=config or {})
backend = storage[str(store_name)]
# Call get_file to resolve the hash to an actual file path
maybe_path = backend.get_file(str(file_hash))
if isinstance(maybe_path, Path):
resolved_file_path = maybe_path
elif isinstance(maybe_path, str) and maybe_path:
# Only treat as a Path if it doesn't look like a URL
if not maybe_path.startswith(("http://", "https://")):
resolved_file_path = Path(maybe_path)
except Exception:
# Fallback: try using the path field from the item
pass
# If backend resolution failed, try the path field
if not resolved_file_path:
path_str = None
if isinstance(item, dict):
path_str = (
item.get("path")
or item.get("PATH")
or item.get("target")
or item.get("filename")
)
else:
# Try attributes for PipeObject/SearchResult/etc.
for attr in ("path", "PATH", "target", "filename"):
if hasattr(item, attr):
val = getattr(item, attr)
if val and isinstance(val, (str, Path)):
path_str = val
break
if path_str:
from SYS.utils import expand_path
resolved_file_path = expand_path(path_str).resolve()
# Now check if it's an image and render it
is_image = False
if resolved_file_path:
try:
if resolved_file_path.suffix.lower() in IMAGE_EXTENSIONS and resolved_file_path.exists():
# Use our image renderer
stdout_console().print()
render_image_to_console(resolved_file_path)
is_image = True
elif resolved_file_path.suffix.lower() in IMAGE_EXTENSIONS and not resolved_file_path.exists():
stdout_console().print(f"[yellow]Warning: Image file not found at {resolved_file_path}[/yellow]")
except Exception:
pass
# Render the comprehensive details panel for the item in either case
item_to_details = item if isinstance(item, dict) else (
item.to_dict() if hasattr(item, "to_dict") else vars(item)
)
# Ensure we include the resolved path if we found one
if resolved_file_path and "path" not in item_to_details:
item_to_details["path"] = str(resolved_file_path)
render_item_details_panel(item_to_details)
ctx.set_last_result_items_only(items)
return
table = ResultTable("Selection Result")
for item in items:
table.add_result(item)

View File

@@ -16,6 +16,8 @@ from typing import Any, Iterator, Sequence, TextIO
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from pathlib import Path
from SYS.utils import expand_path
# Configure Rich pretty-printing to avoid truncating long strings (hashes/paths).
# This is version-safe: older Rich versions may not support the max_* arguments.
@@ -93,3 +95,170 @@ def show_store_config_panel(
"""Show a Rich panel explaining how to configure a storage backend."""
pass
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
def render_image_to_console(image_path: str | Path, max_width: int | None = None) -> None:
"""Render an image to the console using Rich and half-block characters.
Requires Pillow (PIL). If not available or loading fails, does nothing.
"""
try:
from PIL import Image
from rich.color import Color
from rich.style import Style
from rich.text import Text
# Ensure we expand environment variables and resolve to an absolute path.
path = expand_path(image_path).resolve()
if not path.exists() or not path.is_file():
# If absolute path fails, try relative to current directory as a last resort.
path = Path(image_path).resolve()
if not path.exists() or not path.is_file():
return
with Image.open(path) as img:
img = img.convert("RGB")
orig_w, orig_h = img.size
# Determine target dimensions
console = stdout_console()
if max_width is None:
max_width = console.width - 4
# Ensure max_width is at least something reasonable
if max_width < 10:
max_width = 80
# Terminal cells are approx 2x taller than wide.
# Half-block characters (upper half block) let us treat one cell as two vertical pixels.
aspect = orig_h / orig_w
target_w = min(orig_w, max_width)
# Ensure target_h is even so we always have pairs of vertical pixels
target_h = int(target_w * aspect)
if target_h < 2:
target_h = 2
target_h = (target_h // 2) * 2
# Ensure we don't exceed a reasonable height either (e.g. 40 characters)
max_height_chars = 40
if (target_h // 2) > max_height_chars:
target_h = max_height_chars * 2
target_w = int(target_h / aspect)
target_h = (target_h // 2) * 2 # Re-ensure even
if target_w < 1: target_w = 1
img = img.resize((target_w, target_h), Image.Resampling.BILINEAR)
pixels = img.load()
# Render using upper half block (U+2580)
# Each character row in terminal represents 2 pixel rows in image.
for y in range(0, target_h - 1, 2):
line = Text()
for x in range(target_w):
r1, g1, b1 = pixels[x, y]
r2, g2, b2 = pixels[x, y + 1]
# Foreground is top pixel, background is bottom pixel
line.append(
"",
style=Style(
color=Color.from_rgb(r1, g1, b1),
bgcolor=Color.from_rgb(r2, g2, b2),
),
)
console.print(line)
except Exception:
# Silently fail if image cannot be rendered (e.g. missing PIL or corrupted file)
pass
def render_item_details_panel(item: Dict[str, Any]) -> None:
"""Render a comprehensive details panel for a result item."""
from rich.table import Table
from rich.columns import Columns
title = (
item.get("title")
or item.get("name")
or item.get("TITLE")
or "Unnamed Item"
)
# Main layout table for the panel
details_table = Table.grid(expand=True)
details_table.add_column(style="cyan", no_wrap=True, width=15)
details_table.add_column(style="white")
# Basic Info
details_table.add_row("Title", f"[bold]{title}[/bold]")
if "store" in item:
details_table.add_row("Store", str(item["store"]))
if "hash" in item:
details_table.add_row("Hash", str(item["hash"]))
# Metadata / Path
if "path" in item or "target" in item:
path = item.get("path") or item.get("target")
details_table.add_row("Path", str(path))
if "ext" in item or "extension" in item:
ext = item.get("ext") or item.get("extension")
details_table.add_row("Extension", str(ext))
if "size_bytes" in item or "size" in item:
size = item.get("size_bytes") or item.get("size")
if isinstance(size, (int, float)):
if size > 1024 * 1024 * 1024:
size_str = f"{size / (1024*1024*1024):.1f} GB"
elif size > 1024 * 1024:
size_str = f"{size / (1024*1024):.1f} MB"
elif size > 1024:
size_str = f"{size / 1024:.1f} KB"
else:
size_str = f"{size} bytes"
details_table.add_row("Size", size_str)
# URL(s)
urls = item.get("url") or item.get("URL") or []
if isinstance(urls, str):
urls = [urls]
if isinstance(urls, list) and urls:
url_text = "\n".join(map(str, urls))
details_table.add_row("URL(s)", url_text)
# Tags
tags = item.get("tag") or item.get("tags") or []
if isinstance(tags, str):
tags = [tags]
if isinstance(tags, list) and tags:
# Sort and filter tags to look nice
tags_sorted = sorted(map(str, tags))
# Group tags by namespace if they have them
tag_cols = Columns([f"[dim]#[/dim]{t}" for t in tags_sorted], equal=True, expand=True)
details_table.add_row("", "") # Spacer
details_table.add_row("Tags", tag_cols)
# Relationships (if any)
rels = item.get("relationships") or item.get("rel") or []
if isinstance(rels, list) and rels:
rel_text = "\n".join([f"[dim]→[/dim] {r}" for r in rels])
details_table.add_row("Relations", rel_text)
panel = Panel(
details_table,
title=f"[bold green]Item Details[/bold green]",
border_style="green",
padding=(1, 2),
expand=True
)
stdout_console().print()
stdout_console().print(panel)

View File

@@ -316,7 +316,7 @@ class HydrusNetwork(Store):
metadata = client.fetch_file_metadata(
hashes=[file_hash],
include_service_keys_to_tags=False,
include_file_url=False,
include_file_url=True,
include_duration=False,
include_size=False,
include_mime=False,
@@ -808,7 +808,7 @@ class HydrusNetwork(Store):
payload = client.fetch_file_metadata(
file_ids=chunk,
include_service_keys_to_tags=True,
include_file_url=False,
include_file_url=True,
include_duration=True,
include_size=True,
include_mime=True,
@@ -882,11 +882,18 @@ class HydrusNetwork(Store):
)
_collect(display_tags)
file_url = f"{self.URL.rstrip('/')}/get_files/file?hash={hash_hex}"
# Unique tags
all_tags = sorted(list(set(all_tags)))
# 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 []
if not item_url:
item_url = meta.get("file_url") or f"{self.URL.rstrip('/')}/view_file?hash={hash_hex}"
results.append(
{
"hash": hash_hex,
"url": file_url,
"url": item_url,
"name": title,
"title": title,
"size": size,
@@ -912,7 +919,7 @@ class HydrusNetwork(Store):
metadata = client.fetch_file_metadata(
file_ids=file_ids,
include_service_keys_to_tags=True,
include_file_url=False,
include_file_url=True,
include_duration=True,
include_size=True,
include_mime=True,
@@ -922,7 +929,7 @@ class HydrusNetwork(Store):
metadata = client.fetch_file_metadata(
hashes=hashes,
include_service_keys_to_tags=True,
include_file_url=False,
include_file_url=True,
include_duration=True,
include_size=True,
include_mime=True,
@@ -946,7 +953,7 @@ class HydrusNetwork(Store):
metadata = client.fetch_file_metadata(
file_ids=file_ids,
include_service_keys_to_tags=True,
include_file_url=False,
include_file_url=True,
include_duration=True,
include_size=True,
include_mime=True,
@@ -956,7 +963,7 @@ class HydrusNetwork(Store):
metadata = client.fetch_file_metadata(
hashes=hashes,
include_service_keys_to_tags=True,
include_file_url=False,
include_file_url=True,
include_duration=True,
include_size=True,
include_mime=True,
@@ -1020,6 +1027,9 @@ class HydrusNetwork(Store):
# 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.
mime_type = meta.get("mime")
@@ -1038,14 +1048,18 @@ class HydrusNetwork(Store):
# Just include what the tag search returned
has_namespace = ":" in query_lower
# 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 []
if not item_url:
item_url = meta.get("file_url") or f"{self.URL.rstrip('/')}/view_file?hash={hash_hex}"
if has_namespace:
# Explicit namespace search - already filtered by Hydrus tag search
# Include this result as-is
file_url = f"{self.URL.rstrip('/')}/get_files/file?hash={hash_hex}"
results.append(
{
"hash": hash_hex,
"url": file_url,
"url": item_url,
"name": title,
"title": title,
"size": size,
@@ -1074,11 +1088,10 @@ class HydrusNetwork(Store):
break
if match:
file_url = f"{self.URL.rstrip('/')}/get_files/file?hash={hash_hex}"
results.append(
{
"hash": hash_hex,
"url": file_url,
"url": item_url,
"name": title,
"title": title,
"size": size,
@@ -1113,7 +1126,7 @@ class HydrusNetwork(Store):
raise
def get_file(self, file_hash: str, **kwargs: Any) -> Path | str | None:
"""Return a browser URL for the file.
"""Return the local file system path if available, else a browser URL.
IMPORTANT: this method must be side-effect free (do not auto-open a browser).
Only explicit user actions (e.g. the get-file cmdlet) should open files.
@@ -1121,13 +1134,33 @@ class HydrusNetwork(Store):
debug(f"{self._log_prefix()} get_file: start hash={file_hash[:12]}...")
# Build browser URL with access key
# Try to get the local disk path if possible (works if Hydrus is on same machine)
server_path = None
try:
path_res = self._client.get_file_path(file_hash)
if isinstance(path_res, dict) and "path" in path_res:
server_path = path_res["path"]
if server_path:
local_path = Path(server_path)
if local_path.exists():
debug(f"{self._log_prefix()} get_file: found local path: {local_path}")
return local_path
except Exception as e:
debug(f"{self._log_prefix()} get_file: could not resolve path from API: {e}")
# If we found a path on the server but it's not locally accessible,
# return it as a string so it can be displayed in metadata panels.
if server_path:
debug(f"{self._log_prefix()} get_file: returning server path (not local): {server_path}")
return server_path
# Fallback to browser URL with access key
base_url = str(self.URL).rstrip("/")
access_key = str(self.API)
browser_url = (
f"{base_url}/get_files/file?hash={file_hash}&Hydrus-Client-API-Access-Key={access_key}"
)
debug(f"{self._log_prefix()} get_file: url={browser_url}")
debug(f"{self._log_prefix()} get_file: falling back to url={browser_url}")
return browser_url
def download_to_temp(
@@ -1449,7 +1482,7 @@ class HydrusNetwork(Store):
payload = client.fetch_file_metadata(
hashes=[file_hash],
include_service_keys_to_tags=True,
include_file_url=False
include_file_url=True
)
items = payload.get("metadata") if isinstance(payload, dict) else None
@@ -1603,7 +1636,7 @@ class HydrusNetwork(Store):
payload = client.fetch_file_metadata(
hashes=[file_hash],
include_file_url=False
include_file_url=True
)
items = payload.get("metadata") if isinstance(payload, dict) else None
if not isinstance(items, list) or not items:

View File

@@ -1,3 +0,0 @@
####REMOVE THIS####
####place config information below, rename this file to config.conf####
####REMOVE THIS####