This commit is contained in:
2026-02-02 19:49:07 -08:00
parent 8d22ec5a81
commit 1e0000ae19
13 changed files with 297 additions and 988 deletions

View File

@@ -1156,7 +1156,7 @@ class AsyncHTTPClient:
if 400 <= e.response.status_code < 500:
try:
response_text = e.response.text[:500]
except:
except Exception:
response_text = "<unable to read response>"
logger.error(
f"HTTP {e.response.status_code} from {url}: {response_text}"

View File

@@ -56,7 +56,7 @@ _LYRIC_VISIBLE_PROP = "user-data/medeia-lyric-visible"
# Optional overrides set by the playlist controller (.pipe/.mpv) so the lyric
# helper can resolve notes even when the local file path cannot be mapped back
# to a store via the store DB (common for Folder stores).
# to a store via the store DB.
_ITEM_STORE_PROP = "user-data/medeia-item-store"
_ITEM_HASH_PROP = "user-data/medeia-item-hash"
@@ -804,11 +804,7 @@ def _resolve_store_backend_for_target(
except Exception:
return None, None
# Prefer the inferred Folder store (fast), but still validate via get_file().
preferred = _infer_store_for_target(target=target, config=config)
if preferred and preferred in backend_names:
backend_names.remove(preferred)
backend_names.insert(0, preferred)
for name in backend_names:
try:
@@ -842,80 +838,9 @@ def _resolve_store_backend_for_target(
return name, backend
# Fallback for Folder stores:
# If the mpv target is inside a configured Folder store root and the filename
# is hash-named, accept the inferred store even if the store DB doesn't map
# hash->path (e.g. DB missing entry, external copy, etc.).
try:
inferred = _infer_store_for_target(target=target, config=config)
if inferred and inferred in backend_names:
backend = reg[inferred]
if type(backend).__name__ == "Folder":
p = Path(target)
stem = str(p.stem or "").strip().lower()
if stem and stem == str(file_hash or "").strip().lower():
return inferred, backend
except Exception:
pass
return None, None
def _infer_store_for_target(*, target: str, config: dict) -> Optional[str]:
"""Infer store name from the current mpv target (local path under a folder root).
Note: URLs/streams are intentionally not mapped to stores for lyrics.
"""
if isinstance(target, str) and _is_stream_target(target):
return None
try:
from Store import Store as StoreRegistry
reg = StoreRegistry(config, suppress_debug=True)
backends = [(name, reg[name]) for name in reg.list_backends()]
except Exception:
backends = []
# Local file path: choose the deepest Folder root that contains it.
try:
p = Path(target)
if not p.exists() or not p.is_file():
return None
p_str = str(p.resolve()).lower()
except Exception:
return None
best: Optional[str] = None
best_len = -1
for name, backend in backends:
if type(backend).__name__ != "Folder":
continue
root = None
try:
root = (
getattr(backend,
"_location",
None) or getattr(backend,
"location", lambda: None)()
)
except Exception:
root = None
if not root:
continue
try:
root_path = Path(str(root)).expanduser().resolve()
root_str = str(root_path).lower().rstrip("\\/")
except Exception:
continue
if p_str.startswith(root_str) and len(root_str) > best_len:
best = name
best_len = len(root_str)
return best
def _infer_hash_for_target(target: str) -> Optional[str]:
"""Infer SHA256 hash from Hydrus URL query, hash-named local files, or by hashing local file content."""
h = _extract_hash_from_target(target)

View File

@@ -64,6 +64,7 @@ if _ROOT not in sys.path:
from MPV.mpv_ipc import MPVIPCClient # noqa: E402
from SYS.config import load_config # noqa: E402
from SYS.logger import set_debug, debug, set_thread_stream # noqa: E402
from SYS.utils import format_bytes # noqa: E402
REQUEST_PROP = "user-data/medeia-pipeline-request"
RESPONSE_PROP = "user-data/medeia-pipeline-response"
@@ -395,20 +396,8 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]:
ydl_opts["cookiefile"] = cookiefile
def _format_bytes(n: Any) -> str:
try:
v = float(n)
except Exception:
return ""
if v <= 0:
return ""
units = ["B", "KB", "MB", "GB", "TB"]
i = 0
while v >= 1024 and i < len(units) - 1:
v /= 1024.0
i += 1
if i == 0:
return f"{int(v)} {units[i]}"
return f"{v:.1f} {units[i]}"
"""Format bytes using centralized utility."""
return format_bytes(n)
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
info = ydl.extract_info(url, download=False)

View File

@@ -7,6 +7,7 @@ from typing import Any, Dict, List, Optional, Tuple
from ProviderCore.base import Provider, SearchResult
from SYS.logger import log
from SYS.utils import format_bytes
def _get_podcastindex_credentials(config: Dict[str, Any]) -> Tuple[str, str]:
@@ -79,23 +80,8 @@ class PodcastIndex(Provider):
@staticmethod
def _format_bytes(value: Any) -> str:
try:
n = int(value)
except Exception:
return ""
if n <= 0:
return ""
units = ["B", "KB", "MB", "GB", "TB"]
size = float(n)
unit = units[0]
for u in units:
unit = u
if size < 1024.0 or u == units[-1]:
break
size /= 1024.0
if unit == "B":
return f"{int(size)}{unit}"
return f"{size:.1f}{unit}"
"""Format bytes using centralized utility."""
return format_bytes(value)
@staticmethod
def _format_date_from_epoch(value: Any) -> str:

View File

@@ -584,8 +584,20 @@ def set_last_result_table(
items: Optional[List[Any]] = None,
subject: Optional[Any] = None
) -> None:
"""
Store the last result table and items for @ selection syntax.
"""Store the last result table and items for @ selection syntax.
Persists result table and items across command invocations, enabling
subsequent commands to reference and operate on previous results using @N syntax.
Example:
search-file hash:<...> # Returns table with 3 results
@1 | get-metadata # Gets metadata for result #1
@2 | add-tag foo # Adds tag to result #2
Args:
result_table: Table object with results (can be None to clear)
items: List of item objects corresponding to table rows
subject: Optional context object (first item or full list)
"""
state = _get_pipeline_state()
@@ -662,6 +674,16 @@ def set_last_result_table_overlay(
Used by action cmdlets (get-metadata, get-tag, get-url) to display detail
panels or filtered results without disrupting the primary search-result history.
Difference from set_last_result_table():
- Overlay tables are transient (in-process memory only)
- Don't persist across command invocations
- Used for "live" displays that shouldn't be part of @N selection
Args:
result_table: Table object with transient results
items: List of item objects (not persisted)
subject: Optional context object
"""
state = _get_pipeline_state()
state.display_table = result_table
@@ -833,8 +855,17 @@ def get_last_result_table() -> Optional[Any]:
def get_last_result_items() -> List[Any]:
"""
Get the items available for @N selection.
"""Get the items available for @N selection in current pipeline context.
Returns items in priority order:
1. Display items (from get-tag, get-metadata, etc.) if display table is selectable
2. Last result items (from search-file, etc.) if last result table is selectable
3. Empty list if no selectable tables available
Used to resolve @1, @2, etc. in commands.
Returns:
List of items that can be selected via @N syntax
"""
state = _get_pipeline_state()
# Prioritize items from display commands (get-tag, delete-tag, etc.)

View File

@@ -136,7 +136,19 @@ def _get_first_dict_value(data: Dict[str, Any], keys: List[str]) -> Any:
def _as_dict(item: Any) -> Optional[Dict[str, Any]]:
if isinstance(item, dict):
"""Convert any object to dictionary representation.
Handles:
- Dict objects (returned as-is)
- Objects with __dict__ attribute (converted to dict)
- None or conversion failures (returns None)
Args:
item: Object to convert (dict, dataclass, object, etc.)
Returns:
Dictionary representation or None if conversion fails
"""
return item
try:
if hasattr(item, "__dict__"):
@@ -148,7 +160,17 @@ def _as_dict(item: Any) -> Optional[Dict[str, Any]]:
def extract_store_value(item: Any) -> str:
data = _as_dict(item) or {}
"""Extract storage backend name from item.
Searches item for store identifier using multiple field names:
store, table, source, storage (legacy).
Args:
item: Object or dict with store information
Returns:
Store name as string (e.g., "hydrus", "local", "") if not found
"""
store = _get_first_dict_value(
data,
["store",
@@ -1959,7 +1981,33 @@ def format_result(result: Any, title: str = "") -> str:
def extract_item_metadata(item: Any) -> Dict[str, Any]:
"""Extract a comprehensive set of metadata from an item for the ItemDetailView.
Now supports SYS.result_table_api.ResultModel as a first-class input.
Converts items (ResultModel, dicts, objects) into normalized metadata dict.
Extracts all relevant fields for display: Title, Hash, Store, Path, Ext, Size,
Duration, URL, Relations, Tags.
Optimization:
- Calls _as_dict() only once and reuses throughout
- Handles both ResultModel objects and legacy dicts/objects
Example output:
{
"Title": "video.mp4",
"Hash": "abc123def456...",
"Store": "hydrus",
"Path": "/mnt/media/video.mp4",
"Ext": "mp4",
"Size": "1.2 GB",
"Duration": "1h23m",
"Url": "https://example.com/video.mp4",
"Relations": <null>,
"Tags": "movie, comedy"
}
Args:
item: Object to extract metadata from (ResultModel, dict, or any object)
Returns:
Dictionary with standardized metadata fields (empty dict if None input)
"""
if item is None:
return {}
@@ -1996,13 +2044,15 @@ def extract_item_metadata(item: Any) -> Dict[str, Any]:
return out
# Fallback to existing extraction logic for legacy objects/dicts
# Convert once and reuse throughout to avoid repeated _as_dict() calls
data = _as_dict(item) or {}
# Use existing extractors from match-standard result table columns
title = extract_title_value(item)
if title:
out["Title"] = title
else:
# Fallback for raw dicts
data = _as_dict(item) or {}
t = data.get("title") or data.get("name") or data.get("TITLE")
if t: out["Title"] = t
@@ -2013,7 +2063,6 @@ def extract_item_metadata(item: Any) -> Dict[str, Any]:
if store: out["Store"] = store
# Path/Target
data = _as_dict(item) or {}
path = data.get("path") or data.get("target") or data.get("filename")
if path: out["Path"] = path
@@ -2066,6 +2115,23 @@ class ItemDetailView(Table):
This is used for 'get-tag', 'get-url' and similar cmdlets where we want to contextually show
what is being operated on (the main item) along with the selection list.
Display structure:
┌─ Item Details Panel ─────────────────────────────┐
│ Title: video.mp4 │
│ Hash: abc123def456789... │
│ Store: hydrus │
│ Path: /media/video.mp4 │
│ Ext: mp4 │
│ Url: https://example.com/video.mp4 │
└────────────────────────────────────────────────────┘
# TAGS Value
1 .jpg
2 .png
3 .webp
Used by action cmdlets that operate on an item and show its related details.
"""
def __init__(

View File

@@ -552,6 +552,32 @@ def add_direct_link_to_result(
setattr(result, "original_link", original_link)
def extract_hydrus_hash_from_url(url: str) -> str | None:
"""Extract SHA256 hash from Hydrus API URL.
Handles URLs like:
- http://localhost:45869/get_files/file?hash=abc123...
- URLs with &hash=abc123...
Args:
url: URL string to extract hash from
Returns:
Hash hex string (lowercase, 64 chars) if valid SHA256, None otherwise
"""
try:
import re
match = re.search(r"[?&]hash=([0-9a-fA-F]+)", str(url or ""))
if match:
hash_hex = match.group(1).strip().lower()
# Validate SHA256 (exactly 64 hex chars)
if re.fullmatch(r"[0-9a-f]{64}", hash_hex):
return hash_hex
except Exception:
pass
return None
# ============================================================================
# URL Policy Resolution - Consolidated from url_parser.py
# ============================================================================

View File

@@ -13,6 +13,7 @@ from typing import Any, Dict, List, Sequence, Set
from urllib.parse import parse_qs, urlparse
from SYS.logger import log
from SYS.utils import extract_hydrus_hash_from_url
from SYS import pipeline as ctx
from SYS.config import resolve_output_dir
@@ -64,17 +65,8 @@ def _extract_url(item: Any) -> str:
def _extract_hash_from_hydrus_file_url(url: str) -> str:
try:
parsed = urlparse(str(url))
if not (parsed.path or "").endswith("/get_files/file"):
return ""
qs = parse_qs(parsed.query or "")
h = (qs.get("hash") or [""])[0]
if isinstance(h, str) and _SHA256_RE.fullmatch(h.strip()):
return h.strip().lower()
except Exception:
pass
return ""
"""Extract hash from Hydrus URL using centralized utility."""
return extract_hydrus_hash_from_url(url) or ""
def _hydrus_instance_names(config: Dict[str, Any]) -> Set[str]:

View File

@@ -43,7 +43,18 @@ class Get_Metadata(Cmdlet):
@staticmethod
def _extract_imported_ts(meta: Dict[str, Any]) -> Optional[int]:
"""Extract an imported timestamp from metadata if available."""
"""Extract an imported timestamp from metadata if available.
Attempts to parse imported timestamp from metadata dict in multiple formats:
- Numeric Unix timestamp (int/float)
- ISO format string (e.g., "2024-01-15T10:30:00")
Args:
meta: Metadata dictionary from backend (e.g., from get_metadata())
Returns:
Unix timestamp as integer if found, None otherwise
"""
if not isinstance(meta, dict):
return None
@@ -65,7 +76,17 @@ class Get_Metadata(Cmdlet):
@staticmethod
def _format_imported(ts: Optional[int]) -> str:
"""Format timestamp as readable string."""
"""Format Unix timestamp as human-readable date string (UTC).
Converts Unix timestamp to YYYY-MM-DD HH:MM:SS format.
Used for displaying file import dates to users.
Args:
ts: Unix timestamp (integer) or None
Returns:
Formatted date string (e.g., "2024-01-15 10:30:00") or empty string if invalid
"""
if not ts:
return ""
try:
@@ -91,7 +112,30 @@ class Get_Metadata(Cmdlet):
ext: Optional[str] = None,
) -> Dict[str,
Any]:
"""Build a table row dict with metadata fields."""
"""Build a normalized metadata row dict for display and piping.
Converts raw metadata fields into a standardized row format suitable for:
- Display in result tables
- Piping to downstream cmdlets
- JSON serialization
Args:
title: File or resource title
store: Backend store name (e.g., "hydrus", "local")
path: File path or resource identifier
mime: MIME type (e.g., "image/jpeg", "video/mp4")
size_bytes: File size in bytes
dur_seconds: Duration in seconds (for video/audio)
imported_ts: Unix timestamp when item was imported
url: List of known URLs associated with file
hash_value: File hash (SHA256 or other)
pages: Number of pages (for PDFs)
tag: List of tags applied to file
ext: File extension (e.g., "jpg", "mp4")
Returns:
Dictionary with normalized metadata fields and display columns
"""
size_mb = None
size_int: Optional[int] = None
if size_bytes is not None:
@@ -151,7 +195,15 @@ class Get_Metadata(Cmdlet):
@staticmethod
def _add_table_body_row(table: Table, row: Dict[str, Any]) -> None:
"""Add a single row to the ResultTable using the prepared columns."""
"""Add a single metadata row to the result table.
Extracts column values from row dict and adds to result table using
standard column ordering (Hash, MIME, Size, Duration/Pages).
Args:
table: Result table to add row to
row: Metadata row dict (from _build_table_row)
"""
columns = row.get("columns") if isinstance(row, dict) else None
lookup: Dict[str,
Any] = {}
@@ -173,7 +225,25 @@ class Get_Metadata(Cmdlet):
row_obj.add_column("Duration(s)", "")
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Main execution entry point."""
"""Execute get-metadata cmdlet - retrieve and display file metadata.
Queries a storage backend (Hydrus, local, etc.) for file metadata using hash.
Extracts tags embedded in metadata response (avoiding duplicate API calls).
Displays metadata in rich detail panel and result table.
Allows piping (@N) to other cmdlets for chaining operations.
Optimizations:
- Extracts tags from metadata response (no separate get_tag() call)
- Single HTTP request to backends per file
Args:
result: Piped input (dict with optional hash/store/title/tag fields)
args: Command line arguments ([-query "hash:..."] [-store backend])
config: Application configuration dict
Returns:
0 on success, 1 on error (no metadata found, backend unavailable, etc.)
"""
# Parse arguments
parsed = parse_cmdlet_args(args, self)

View File

@@ -318,8 +318,24 @@ def _emit_tags_as_table(
) -> None:
"""Emit tags as TagItem objects and display via ResultTable.
This replaces _print_tag_list to make tags pipe-able.
Stores the table via ctx.set_last_result_table_overlay (or ctx.set_last_result_table) for downstream @ selection.
Displays tags in a rich detail panel with file context (hash, title, URL, etc).
Creates a table of individual tag items to allow selection and downstream piping.
Preserves all metadata from subject (URLs, extensions, etc.) through to display.
Makes tags @-selectable via ctx.set_last_result_table() for chaining:
- get-tag @1 | delete-tag (remove a specific tag)
- get-tag @2 | add-url (add URL to tagged file)
Args:
tags_list: List of tag strings to display
file_hash: SHA256 hash of file
store: Backend name (e.g., "hydrus", "local", "url")
service_name: Tag service name (if from Hydrus)
config: Application configuration
item_title: Optional file title to display
path: Optional file path
subject: Full context object (should preserve original metadata)
quiet: If True, don't display (emit-only mode)
"""
from SYS.result_table import ItemDetailView, extract_item_metadata

View File

@@ -389,7 +389,27 @@ class search_file(Cmdlet):
# --- Execution ------------------------------------------------------
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Search storage backends for files."""
"""Search storage backends for files by various criteria.
Supports searching by:
- Hash (-query "hash:...")
- Title (-query "title:...")
- Tag (-query "tag:...")
- URL (-query "url:...")
- Other backend-specific fields
Optimizations:
- Extracts tags from metadata response (avoids duplicate API calls)
- Only calls get_tag() separately for backends that don't include tags
Args:
result: Piped input (typically empty for new search)
args: Search criteria and options
config: Application configuration
Returns:
0 on success, 1 on error
"""
if should_show_help(args):
log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}")
return 0
@@ -698,20 +718,43 @@ class search_file(Cmdlet):
except Exception:
meta_obj = {}
# Extract tags from metadata response instead of separate get_tag() call
# Metadata already includes tags if fetched with include_service_keys_to_tags=True
tags_list: List[str] = []
try:
tag_result = resolved_backend.get_tag(h)
if isinstance(tag_result, tuple) and tag_result:
maybe_tags = tag_result[0]
else:
maybe_tags = tag_result
if isinstance(maybe_tags, list):
tags_list = [
str(t).strip() for t in maybe_tags
if isinstance(t, str) and str(t).strip()
]
except Exception:
tags_list = []
# First try to extract from metadata tags dict
metadata_tags = meta_obj.get("tags")
if isinstance(metadata_tags, dict):
for service_data in metadata_tags.values():
if isinstance(service_data, dict):
display_tags = service_data.get("display_tags", {})
if isinstance(display_tags, dict):
for tag_list in display_tags.values():
if isinstance(tag_list, list):
tags_list = [
str(t).strip() for t in tag_list
if isinstance(t, str) and str(t).strip()
]
break
if tags_list:
break
# Fallback: if metadata didn't include tags, call get_tag() separately
# (This maintains compatibility with backends that don't include tags in metadata)
if not tags_list:
try:
tag_result = resolved_backend.get_tag(h)
if isinstance(tag_result, tuple) and tag_result:
maybe_tags = tag_result[0]
else:
maybe_tags = tag_result
if isinstance(maybe_tags, list):
tags_list = [
str(t).strip() for t in maybe_tags
if isinstance(t, str) and str(t).strip()
]
except Exception:
tags_list = []
title_from_tag: Optional[str] = None
try:

View File

@@ -12,6 +12,7 @@ from cmdlet._shared import Cmdlet, CmdletArg
from SYS.config import load_config, save_config
from SYS.logger import log, debug
from SYS.result_table import Table
from SYS.utils import extract_hydrus_hash_from_url
from SYS import pipeline as ctx
_MATRIX_PENDING_ITEMS_KEY = "matrix_pending_items"
@@ -543,17 +544,8 @@ def _extract_sha256_hex(item: Any) -> Optional[str]:
def _extract_hash_from_hydrus_file_url(url: str) -> Optional[str]:
try:
parsed = urlparse(url)
if not (parsed.path or "").endswith("/get_files/file"):
return None
qs = parse_qs(parsed.query or "")
h = (qs.get("hash") or [None])[0]
if isinstance(h, str) and _SHA256_RE.fullmatch(h.strip()):
return h.strip().lower()
except Exception:
pass
return None
"""Extract hash from Hydrus URL using centralized utility."""
return extract_hydrus_hash_from_url(url)
def _maybe_download_hydrus_file(item: Any,

File diff suppressed because one or more lines are too long