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.markdown import Markdown
from rich.bar import Bar from rich.bar import Bar
from rich.table import Table 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: def _install_rich_traceback(*, show_locals: bool = False) -> None:
@@ -4138,6 +4144,93 @@ class PipelineExecutor:
ctx.set_last_result_items_only(items) ctx.set_last_result_items_only(items)
return 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") table = ResultTable("Selection Result")
for item in items: for item in items:
table.add_result(item) table.add_result(item)

View File

@@ -16,6 +16,8 @@ from typing import Any, Iterator, Sequence, TextIO
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.text import Text 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). # Configure Rich pretty-printing to avoid truncating long strings (hashes/paths).
# This is version-safe: older Rich versions may not support the max_* arguments. # 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.""" """Show a Rich panel explaining how to configure a storage backend."""
pass 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( 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_url=False, include_file_url=True,
include_duration=False, include_duration=False,
include_size=False, include_size=False,
include_mime=False, include_mime=False,
@@ -808,7 +808,7 @@ class HydrusNetwork(Store):
payload = client.fetch_file_metadata( payload = client.fetch_file_metadata(
file_ids=chunk, file_ids=chunk,
include_service_keys_to_tags=True, include_service_keys_to_tags=True,
include_file_url=False, include_file_url=True,
include_duration=True, include_duration=True,
include_size=True, include_size=True,
include_mime=True, include_mime=True,
@@ -882,11 +882,18 @@ class HydrusNetwork(Store):
) )
_collect(display_tags) _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( results.append(
{ {
"hash": hash_hex, "hash": hash_hex,
"url": file_url, "url": item_url,
"name": title, "name": title,
"title": title, "title": title,
"size": size, "size": size,
@@ -912,7 +919,7 @@ class HydrusNetwork(Store):
metadata = client.fetch_file_metadata( metadata = client.fetch_file_metadata(
file_ids=file_ids, file_ids=file_ids,
include_service_keys_to_tags=True, include_service_keys_to_tags=True,
include_file_url=False, include_file_url=True,
include_duration=True, include_duration=True,
include_size=True, include_size=True,
include_mime=True, include_mime=True,
@@ -922,7 +929,7 @@ class HydrusNetwork(Store):
metadata = client.fetch_file_metadata( metadata = client.fetch_file_metadata(
hashes=hashes, hashes=hashes,
include_service_keys_to_tags=True, include_service_keys_to_tags=True,
include_file_url=False, include_file_url=True,
include_duration=True, include_duration=True,
include_size=True, include_size=True,
include_mime=True, include_mime=True,
@@ -946,7 +953,7 @@ class HydrusNetwork(Store):
metadata = client.fetch_file_metadata( metadata = client.fetch_file_metadata(
file_ids=file_ids, file_ids=file_ids,
include_service_keys_to_tags=True, include_service_keys_to_tags=True,
include_file_url=False, include_file_url=True,
include_duration=True, include_duration=True,
include_size=True, include_size=True,
include_mime=True, include_mime=True,
@@ -956,7 +963,7 @@ class HydrusNetwork(Store):
metadata = client.fetch_file_metadata( metadata = client.fetch_file_metadata(
hashes=hashes, hashes=hashes,
include_service_keys_to_tags=True, include_service_keys_to_tags=True,
include_file_url=False, include_file_url=True,
include_duration=True, include_duration=True,
include_size=True, include_size=True,
include_mime=True, include_mime=True,
@@ -1021,6 +1028,9 @@ class HydrusNetwork(Store):
top_level_tags = meta.get("tags_flat", []) or meta.get("tags", []) top_level_tags = meta.get("tags_flat", []) or meta.get("tags", [])
_collect(top_level_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")
ext = str(meta.get("ext") or "").strip().lstrip(".") ext = str(meta.get("ext") or "").strip().lstrip(".")
@@ -1038,14 +1048,18 @@ class HydrusNetwork(Store):
# Just include what the tag search returned # Just include what the tag search returned
has_namespace = ":" in query_lower 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: if has_namespace:
# Explicit namespace search - already filtered by Hydrus tag search # Explicit namespace search - already filtered by Hydrus tag search
# Include this result as-is # Include this result as-is
file_url = f"{self.URL.rstrip('/')}/get_files/file?hash={hash_hex}"
results.append( results.append(
{ {
"hash": hash_hex, "hash": hash_hex,
"url": file_url, "url": item_url,
"name": title, "name": title,
"title": title, "title": title,
"size": size, "size": size,
@@ -1074,11 +1088,10 @@ class HydrusNetwork(Store):
break break
if match: if match:
file_url = f"{self.URL.rstrip('/')}/get_files/file?hash={hash_hex}"
results.append( results.append(
{ {
"hash": hash_hex, "hash": hash_hex,
"url": file_url, "url": item_url,
"name": title, "name": title,
"title": title, "title": title,
"size": size, "size": size,
@@ -1113,7 +1126,7 @@ class HydrusNetwork(Store):
raise raise
def get_file(self, file_hash: str, **kwargs: Any) -> Path | str | None: 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). 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. 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]}...") 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("/") base_url = str(self.URL).rstrip("/")
access_key = str(self.API) access_key = str(self.API)
browser_url = ( browser_url = (
f"{base_url}/get_files/file?hash={file_hash}&Hydrus-Client-API-Access-Key={access_key}" 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 return browser_url
def download_to_temp( def download_to_temp(
@@ -1449,7 +1482,7 @@ class HydrusNetwork(Store):
payload = client.fetch_file_metadata( payload = client.fetch_file_metadata(
hashes=[file_hash], hashes=[file_hash],
include_service_keys_to_tags=True, include_service_keys_to_tags=True,
include_file_url=False include_file_url=True
) )
items = payload.get("metadata") if isinstance(payload, dict) else None items = payload.get("metadata") if isinstance(payload, dict) else None
@@ -1603,7 +1636,7 @@ class HydrusNetwork(Store):
payload = client.fetch_file_metadata( payload = client.fetch_file_metadata(
hashes=[file_hash], hashes=[file_hash],
include_file_url=False include_file_url=True
) )
items = payload.get("metadata") if isinstance(payload, dict) else None items = payload.get("metadata") if isinstance(payload, dict) else None
if not isinstance(items, list) or not items: 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####