ff
This commit is contained in:
@@ -1156,7 +1156,7 @@ class AsyncHTTPClient:
|
|||||||
if 400 <= e.response.status_code < 500:
|
if 400 <= e.response.status_code < 500:
|
||||||
try:
|
try:
|
||||||
response_text = e.response.text[:500]
|
response_text = e.response.text[:500]
|
||||||
except:
|
except Exception:
|
||||||
response_text = "<unable to read response>"
|
response_text = "<unable to read response>"
|
||||||
logger.error(
|
logger.error(
|
||||||
f"HTTP {e.response.status_code} from {url}: {response_text}"
|
f"HTTP {e.response.status_code} from {url}: {response_text}"
|
||||||
|
|||||||
79
MPV/lyric.py
79
MPV/lyric.py
@@ -56,7 +56,7 @@ _LYRIC_VISIBLE_PROP = "user-data/medeia-lyric-visible"
|
|||||||
|
|
||||||
# Optional overrides set by the playlist controller (.pipe/.mpv) so the lyric
|
# 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
|
# 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_STORE_PROP = "user-data/medeia-item-store"
|
||||||
_ITEM_HASH_PROP = "user-data/medeia-item-hash"
|
_ITEM_HASH_PROP = "user-data/medeia-item-hash"
|
||||||
|
|
||||||
@@ -804,11 +804,7 @@ def _resolve_store_backend_for_target(
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None, None
|
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:
|
for name in backend_names:
|
||||||
try:
|
try:
|
||||||
@@ -842,80 +838,9 @@ def _resolve_store_backend_for_target(
|
|||||||
|
|
||||||
return name, backend
|
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
|
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]:
|
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."""
|
"""Infer SHA256 hash from Hydrus URL query, hash-named local files, or by hashing local file content."""
|
||||||
h = _extract_hash_from_target(target)
|
h = _extract_hash_from_target(target)
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ if _ROOT not in sys.path:
|
|||||||
from MPV.mpv_ipc import MPVIPCClient # noqa: E402
|
from MPV.mpv_ipc import MPVIPCClient # noqa: E402
|
||||||
from SYS.config import load_config # noqa: E402
|
from SYS.config import load_config # noqa: E402
|
||||||
from SYS.logger import set_debug, debug, set_thread_stream # 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"
|
REQUEST_PROP = "user-data/medeia-pipeline-request"
|
||||||
RESPONSE_PROP = "user-data/medeia-pipeline-response"
|
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
|
ydl_opts["cookiefile"] = cookiefile
|
||||||
|
|
||||||
def _format_bytes(n: Any) -> str:
|
def _format_bytes(n: Any) -> str:
|
||||||
try:
|
"""Format bytes using centralized utility."""
|
||||||
v = float(n)
|
return format_bytes(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]}"
|
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||||||
|
|
||||||
from ProviderCore.base import Provider, SearchResult
|
from ProviderCore.base import Provider, SearchResult
|
||||||
from SYS.logger import log
|
from SYS.logger import log
|
||||||
|
from SYS.utils import format_bytes
|
||||||
|
|
||||||
|
|
||||||
def _get_podcastindex_credentials(config: Dict[str, Any]) -> Tuple[str, str]:
|
def _get_podcastindex_credentials(config: Dict[str, Any]) -> Tuple[str, str]:
|
||||||
@@ -79,23 +80,8 @@ class PodcastIndex(Provider):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_bytes(value: Any) -> str:
|
def _format_bytes(value: Any) -> str:
|
||||||
try:
|
"""Format bytes using centralized utility."""
|
||||||
n = int(value)
|
return format_bytes(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}"
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_date_from_epoch(value: Any) -> str:
|
def _format_date_from_epoch(value: Any) -> str:
|
||||||
|
|||||||
@@ -584,8 +584,20 @@ def set_last_result_table(
|
|||||||
items: Optional[List[Any]] = None,
|
items: Optional[List[Any]] = None,
|
||||||
subject: Optional[Any] = None
|
subject: Optional[Any] = None
|
||||||
) -> 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()
|
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
|
Used by action cmdlets (get-metadata, get-tag, get-url) to display detail
|
||||||
panels or filtered results without disrupting the primary search-result history.
|
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 = _get_pipeline_state()
|
||||||
state.display_table = result_table
|
state.display_table = result_table
|
||||||
@@ -833,8 +855,17 @@ def get_last_result_table() -> Optional[Any]:
|
|||||||
|
|
||||||
|
|
||||||
def get_last_result_items() -> List[Any]:
|
def get_last_result_items() -> List[Any]:
|
||||||
"""
|
"""Get the items available for @N selection in current pipeline context.
|
||||||
Get the items available for @N selection.
|
|
||||||
|
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()
|
state = _get_pipeline_state()
|
||||||
# Prioritize items from display commands (get-tag, delete-tag, etc.)
|
# Prioritize items from display commands (get-tag, delete-tag, etc.)
|
||||||
|
|||||||
@@ -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]]:
|
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
|
return item
|
||||||
try:
|
try:
|
||||||
if hasattr(item, "__dict__"):
|
if hasattr(item, "__dict__"):
|
||||||
@@ -148,7 +160,17 @@ def _as_dict(item: Any) -> Optional[Dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
def extract_store_value(item: Any) -> str:
|
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(
|
store = _get_first_dict_value(
|
||||||
data,
|
data,
|
||||||
["store",
|
["store",
|
||||||
@@ -1959,7 +1981,33 @@ def format_result(result: Any, title: str = "") -> str:
|
|||||||
def extract_item_metadata(item: Any) -> Dict[str, Any]:
|
def extract_item_metadata(item: Any) -> Dict[str, Any]:
|
||||||
"""Extract a comprehensive set of metadata from an item for the ItemDetailView.
|
"""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:
|
if item is None:
|
||||||
return {}
|
return {}
|
||||||
@@ -1996,13 +2044,15 @@ def extract_item_metadata(item: Any) -> Dict[str, Any]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
# Fallback to existing extraction logic for legacy objects/dicts
|
# 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
|
# Use existing extractors from match-standard result table columns
|
||||||
title = extract_title_value(item)
|
title = extract_title_value(item)
|
||||||
if title:
|
if title:
|
||||||
out["Title"] = title
|
out["Title"] = title
|
||||||
else:
|
else:
|
||||||
# Fallback for raw dicts
|
# Fallback for raw dicts
|
||||||
data = _as_dict(item) or {}
|
|
||||||
t = data.get("title") or data.get("name") or data.get("TITLE")
|
t = data.get("title") or data.get("name") or data.get("TITLE")
|
||||||
if t: out["Title"] = t
|
if t: out["Title"] = t
|
||||||
|
|
||||||
@@ -2013,7 +2063,6 @@ def extract_item_metadata(item: Any) -> Dict[str, Any]:
|
|||||||
if store: out["Store"] = store
|
if store: out["Store"] = store
|
||||||
|
|
||||||
# Path/Target
|
# Path/Target
|
||||||
data = _as_dict(item) or {}
|
|
||||||
path = data.get("path") or data.get("target") or data.get("filename")
|
path = data.get("path") or data.get("target") or data.get("filename")
|
||||||
if path: out["Path"] = path
|
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
|
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.
|
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__(
|
def __init__(
|
||||||
|
|||||||
26
SYS/utils.py
26
SYS/utils.py
@@ -552,6 +552,32 @@ def add_direct_link_to_result(
|
|||||||
setattr(result, "original_link", original_link)
|
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
|
# URL Policy Resolution - Consolidated from url_parser.py
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from typing import Any, Dict, List, Sequence, Set
|
|||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from SYS.logger import log
|
from SYS.logger import log
|
||||||
|
from SYS.utils import extract_hydrus_hash_from_url
|
||||||
|
|
||||||
from SYS import pipeline as ctx
|
from SYS import pipeline as ctx
|
||||||
from SYS.config import resolve_output_dir
|
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:
|
def _extract_hash_from_hydrus_file_url(url: str) -> str:
|
||||||
try:
|
"""Extract hash from Hydrus URL using centralized utility."""
|
||||||
parsed = urlparse(str(url))
|
return extract_hydrus_hash_from_url(url) or ""
|
||||||
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 ""
|
|
||||||
|
|
||||||
|
|
||||||
def _hydrus_instance_names(config: Dict[str, Any]) -> Set[str]:
|
def _hydrus_instance_names(config: Dict[str, Any]) -> Set[str]:
|
||||||
|
|||||||
@@ -43,7 +43,18 @@ class Get_Metadata(Cmdlet):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_imported_ts(meta: Dict[str, Any]) -> Optional[int]:
|
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):
|
if not isinstance(meta, dict):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -65,7 +76,17 @@ class Get_Metadata(Cmdlet):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_imported(ts: Optional[int]) -> str:
|
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:
|
if not ts:
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
@@ -91,7 +112,30 @@ class Get_Metadata(Cmdlet):
|
|||||||
ext: Optional[str] = None,
|
ext: Optional[str] = None,
|
||||||
) -> Dict[str,
|
) -> Dict[str,
|
||||||
Any]:
|
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_mb = None
|
||||||
size_int: Optional[int] = None
|
size_int: Optional[int] = None
|
||||||
if size_bytes is not None:
|
if size_bytes is not None:
|
||||||
@@ -151,7 +195,15 @@ class Get_Metadata(Cmdlet):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _add_table_body_row(table: Table, row: Dict[str, Any]) -> None:
|
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
|
columns = row.get("columns") if isinstance(row, dict) else None
|
||||||
lookup: Dict[str,
|
lookup: Dict[str,
|
||||||
Any] = {}
|
Any] = {}
|
||||||
@@ -173,7 +225,25 @@ class Get_Metadata(Cmdlet):
|
|||||||
row_obj.add_column("Duration(s)", "")
|
row_obj.add_column("Duration(s)", "")
|
||||||
|
|
||||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
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
|
# Parse arguments
|
||||||
parsed = parse_cmdlet_args(args, self)
|
parsed = parse_cmdlet_args(args, self)
|
||||||
|
|
||||||
|
|||||||
@@ -318,8 +318,24 @@ def _emit_tags_as_table(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Emit tags as TagItem objects and display via ResultTable.
|
"""Emit tags as TagItem objects and display via ResultTable.
|
||||||
|
|
||||||
This replaces _print_tag_list to make tags pipe-able.
|
Displays tags in a rich detail panel with file context (hash, title, URL, etc).
|
||||||
Stores the table via ctx.set_last_result_table_overlay (or ctx.set_last_result_table) for downstream @ selection.
|
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
|
from SYS.result_table import ItemDetailView, extract_item_metadata
|
||||||
|
|
||||||
|
|||||||
@@ -389,7 +389,27 @@ class search_file(Cmdlet):
|
|||||||
|
|
||||||
# --- Execution ------------------------------------------------------
|
# --- Execution ------------------------------------------------------
|
||||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
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):
|
if should_show_help(args):
|
||||||
log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}")
|
log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}")
|
||||||
return 0
|
return 0
|
||||||
@@ -698,20 +718,43 @@ class search_file(Cmdlet):
|
|||||||
except Exception:
|
except Exception:
|
||||||
meta_obj = {}
|
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] = []
|
tags_list: List[str] = []
|
||||||
try:
|
|
||||||
tag_result = resolved_backend.get_tag(h)
|
# First try to extract from metadata tags dict
|
||||||
if isinstance(tag_result, tuple) and tag_result:
|
metadata_tags = meta_obj.get("tags")
|
||||||
maybe_tags = tag_result[0]
|
if isinstance(metadata_tags, dict):
|
||||||
else:
|
for service_data in metadata_tags.values():
|
||||||
maybe_tags = tag_result
|
if isinstance(service_data, dict):
|
||||||
if isinstance(maybe_tags, list):
|
display_tags = service_data.get("display_tags", {})
|
||||||
tags_list = [
|
if isinstance(display_tags, dict):
|
||||||
str(t).strip() for t in maybe_tags
|
for tag_list in display_tags.values():
|
||||||
if isinstance(t, str) and str(t).strip()
|
if isinstance(tag_list, list):
|
||||||
]
|
tags_list = [
|
||||||
except Exception:
|
str(t).strip() for t in tag_list
|
||||||
tags_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
|
title_from_tag: Optional[str] = None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from cmdlet._shared import Cmdlet, CmdletArg
|
|||||||
from SYS.config import load_config, save_config
|
from SYS.config import load_config, save_config
|
||||||
from SYS.logger import log, debug
|
from SYS.logger import log, debug
|
||||||
from SYS.result_table import Table
|
from SYS.result_table import Table
|
||||||
|
from SYS.utils import extract_hydrus_hash_from_url
|
||||||
from SYS import pipeline as ctx
|
from SYS import pipeline as ctx
|
||||||
|
|
||||||
_MATRIX_PENDING_ITEMS_KEY = "matrix_pending_items"
|
_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]:
|
def _extract_hash_from_hydrus_file_url(url: str) -> Optional[str]:
|
||||||
try:
|
"""Extract hash from Hydrus URL using centralized utility."""
|
||||||
parsed = urlparse(url)
|
return extract_hydrus_hash_from_url(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
|
|
||||||
|
|
||||||
|
|
||||||
def _maybe_download_hydrus_file(item: Any,
|
def _maybe_download_hydrus_file(item: Any,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user