1025 lines
43 KiB
Python
1025 lines
43 KiB
Python
"""search-file cmdlet: Search for files in storage backends (Hydrus)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, Sequence, List, Optional
|
|
import uuid
|
|
from pathlib import Path
|
|
import re
|
|
import json
|
|
import sys
|
|
|
|
from SYS.logger import log, debug
|
|
from ProviderCore.registry import get_search_provider, list_search_providers
|
|
from SYS.rich_display import (
|
|
show_provider_config_panel,
|
|
show_store_config_panel,
|
|
show_available_providers_panel,
|
|
)
|
|
from SYS.database import insert_worker, update_worker, append_worker_stdout
|
|
|
|
from ._shared import (
|
|
Cmdlet,
|
|
CmdletArg,
|
|
SharedArgs,
|
|
get_field,
|
|
should_show_help,
|
|
normalize_hash,
|
|
first_title_tag,
|
|
parse_hash_query,
|
|
)
|
|
from SYS import pipeline as ctx
|
|
|
|
STORAGE_ORIGINS = {"local",
|
|
"hydrus",
|
|
"zerotier"}
|
|
|
|
|
|
class _WorkerLogger:
|
|
def __init__(self, worker_id: str) -> None:
|
|
self.worker_id = worker_id
|
|
|
|
def __enter__(self) -> "_WorkerLogger":
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None: # type: ignore[override]
|
|
return None
|
|
|
|
def insert_worker(
|
|
self,
|
|
worker_id: str,
|
|
worker_type: str,
|
|
title: str = "",
|
|
description: str = "",
|
|
**kwargs: Any,
|
|
) -> None:
|
|
try:
|
|
insert_worker(worker_id, worker_type, title=title, description=description)
|
|
except Exception:
|
|
pass
|
|
|
|
def update_worker_status(self, worker_id: str, status: str) -> None:
|
|
try:
|
|
update_worker(worker_id, status=status)
|
|
except Exception:
|
|
pass
|
|
|
|
def append_worker_stdout(self, worker_id: str, content: str) -> None:
|
|
try:
|
|
append_worker_stdout(worker_id, content)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class search_file(Cmdlet):
|
|
"""Class-based search-file cmdlet for searching storage backends."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(
|
|
name="search-file",
|
|
summary="Search storage backends (Hydrus) or external providers (via -provider).",
|
|
usage="search-file [-query <query>] [-store BACKEND] [-limit N] [-provider NAME]",
|
|
arg=[
|
|
CmdletArg(
|
|
"limit",
|
|
type="integer",
|
|
description="Limit results (default: 100)"
|
|
),
|
|
SharedArgs.STORE,
|
|
SharedArgs.QUERY,
|
|
CmdletArg(
|
|
"provider",
|
|
type="string",
|
|
description="External provider name (e.g., tidal, youtube, soulseek, etc)",
|
|
choices=["bandcamp", "libgen", "soulseek", "youtube", "alldebrid", "loc", "internetarchive", "tidal", "tidal"],
|
|
),
|
|
CmdletArg(
|
|
"open",
|
|
type="integer",
|
|
description="(alldebrid) Open folder/magnet by ID and list its files",
|
|
),
|
|
],
|
|
detail=[
|
|
"Search across storage backends: Hydrus instances",
|
|
"Use -store to search a specific backend by name",
|
|
"URL search: url:* (any URL) or url:<value> (URL substring)",
|
|
"Extension search: ext:<value> (e.g., ext:png)",
|
|
"Hydrus-style extension: system:filetype = png",
|
|
"Results include hash for downstream commands (get-file, add-tag, etc.)",
|
|
"Examples:",
|
|
"search-file -query foo # Search all storage backends",
|
|
"search-file -store home -query '*' # Search 'home' Hydrus instance",
|
|
"search-file -store home -query 'video' # Search 'home' Hydrus instance",
|
|
"search-file -query 'hash:deadbeef...' # Search by SHA256 hash",
|
|
"search-file -query 'url:*' # Files that have any URL",
|
|
"search-file -query 'url:youtube.com' # Files whose URL contains substring",
|
|
"search-file -query 'ext:png' # Files whose metadata ext is png",
|
|
"search-file -query 'system:filetype = png' # Hydrus: native",
|
|
"",
|
|
"Provider search (-provider):",
|
|
"search-file -provider youtube 'tutorial' # Search YouTube provider",
|
|
"search-file -provider alldebrid '*' # List AllDebrid magnets",
|
|
"search-file -provider alldebrid -open 123 '*' # Show files for a magnet",
|
|
],
|
|
exec=self.run,
|
|
)
|
|
self.register()
|
|
|
|
# --- Helper methods -------------------------------------------------
|
|
@staticmethod
|
|
def _normalize_extension(ext_value: Any) -> str:
|
|
"""Sanitize extension strings to alphanumerics and cap at 5 chars."""
|
|
ext = str(ext_value or "").strip().lstrip(".")
|
|
for sep in (" ", "|", "(", "[", "{", ",", ";"):
|
|
if sep in ext:
|
|
ext = ext.split(sep, 1)[0]
|
|
break
|
|
if "." in ext:
|
|
ext = ext.split(".")[-1]
|
|
ext = "".join(ch for ch in ext if ch.isalnum())
|
|
return ext[:5]
|
|
|
|
@staticmethod
|
|
def _get_tidal_view_from_query(query: str) -> str:
|
|
text = str(query or "").strip()
|
|
if not text:
|
|
return "track"
|
|
if re.search(r"\balbum\s*:", text, flags=re.IGNORECASE):
|
|
return "album"
|
|
if re.search(r"\bartist\s*:", text, flags=re.IGNORECASE):
|
|
return "artist"
|
|
return "track"
|
|
|
|
def _ensure_storage_columns(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Ensure storage results have the necessary fields for result_table display."""
|
|
store_value = str(payload.get("store") or "").lower()
|
|
if store_value not in STORAGE_ORIGINS:
|
|
return payload
|
|
|
|
# Ensure we have title field
|
|
if "title" not in payload:
|
|
payload["title"] = (
|
|
payload.get("name") or payload.get("target") or payload.get("path")
|
|
or "Result"
|
|
)
|
|
|
|
# Ensure we have ext field
|
|
if "ext" not in payload:
|
|
title = str(payload.get("title", ""))
|
|
path_obj = Path(title)
|
|
if path_obj.suffix:
|
|
payload["ext"] = self._normalize_extension(path_obj.suffix.lstrip("."))
|
|
else:
|
|
payload["ext"] = payload.get("ext", "")
|
|
|
|
# Ensure size_bytes is present for display (already set by search_file())
|
|
# result_table will handle formatting it
|
|
|
|
# Don't create manual columns - let result_table handle display
|
|
# This allows the table to respect max_columns and apply consistent formatting
|
|
return payload
|
|
|
|
def _run_provider_search(
|
|
self,
|
|
*,
|
|
provider_name: str,
|
|
query: str,
|
|
limit: int,
|
|
limit_set: bool,
|
|
open_id: Optional[int],
|
|
args_list: List[str],
|
|
refresh_mode: bool,
|
|
config: Dict[str, Any],
|
|
) -> int:
|
|
"""Execute external provider search."""
|
|
|
|
if not provider_name or not query:
|
|
from SYS import pipeline as ctx_mod
|
|
progress = None
|
|
if hasattr(ctx_mod, "get_pipeline_state"):
|
|
progress = ctx_mod.get_pipeline_state().live_progress
|
|
if progress:
|
|
try:
|
|
progress.stop()
|
|
except Exception:
|
|
pass
|
|
|
|
log("Error: search-file -provider requires both provider and query", file=sys.stderr)
|
|
log(f"Usage: {self.usage}", file=sys.stderr)
|
|
|
|
providers_map = list_search_providers(config)
|
|
available = [n for n, a in providers_map.items() if a]
|
|
unconfigured = [n for n, a in providers_map.items() if not a]
|
|
|
|
if unconfigured:
|
|
show_provider_config_panel(unconfigured)
|
|
|
|
if available:
|
|
show_available_providers_panel(available)
|
|
|
|
return 1
|
|
|
|
# Align with provider default when user did not set -limit.
|
|
if not limit_set:
|
|
limit = 50
|
|
|
|
from SYS import pipeline as ctx_mod
|
|
progress = None
|
|
if hasattr(ctx_mod, "get_pipeline_state"):
|
|
progress = ctx_mod.get_pipeline_state().live_progress
|
|
|
|
provider = get_search_provider(provider_name, config)
|
|
if not provider:
|
|
if progress:
|
|
try:
|
|
progress.stop()
|
|
except Exception:
|
|
pass
|
|
|
|
show_provider_config_panel([provider_name])
|
|
|
|
providers_map = list_search_providers(config)
|
|
available = [n for n, a in providers_map.items() if a]
|
|
if available:
|
|
show_available_providers_panel(available)
|
|
return 1
|
|
|
|
worker_id = str(uuid.uuid4())
|
|
try:
|
|
insert_worker(
|
|
worker_id,
|
|
"search-file",
|
|
title=f"Search: {query}",
|
|
description=f"Provider: {provider_name}, Query: {query}",
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
results_list: List[Dict[str, Any]] = []
|
|
|
|
from SYS.result_table import Table
|
|
|
|
provider_text = str(provider_name or "").strip()
|
|
provider_lower = provider_text.lower()
|
|
id_match = re.search(r"\bid\s*[=:]\s*(\d+)", query, flags=re.IGNORECASE)
|
|
parsed_open_id = open_id
|
|
if id_match and parsed_open_id is None:
|
|
try:
|
|
parsed_open_id = int(id_match.group(1))
|
|
except Exception:
|
|
parsed_open_id = None
|
|
query = re.sub(r"\bid\s*[=:]\s*\d+", "", query, flags=re.IGNORECASE).strip()
|
|
if not query:
|
|
query = "*"
|
|
|
|
effective_open_id = parsed_open_id if parsed_open_id is not None else open_id
|
|
if provider_lower == "youtube":
|
|
provider_label = "Youtube"
|
|
elif provider_lower == "openlibrary":
|
|
provider_label = "OpenLibrary"
|
|
elif provider_lower == "loc":
|
|
provider_label = "LoC"
|
|
else:
|
|
provider_label = provider_text[:1].upper() + provider_text[1:] if provider_text else "Provider"
|
|
|
|
normalized_query = str(query or "").strip()
|
|
provider_filters: Dict[str, Any] = {}
|
|
try:
|
|
normalized_query, provider_filters = provider.extract_query_arguments(query)
|
|
except Exception:
|
|
provider_filters = {}
|
|
normalized_query = (normalized_query or "").strip()
|
|
query = normalized_query or "*"
|
|
provider_filters = dict(provider_filters or {})
|
|
|
|
if provider_lower == "alldebrid" and effective_open_id is not None:
|
|
table_title = f"{provider_label} Files: {effective_open_id}".strip().rstrip(":")
|
|
else:
|
|
table_title = f"{provider_label}: {query}".strip().rstrip(":")
|
|
|
|
preserve_order = provider_lower in {"youtube", "openlibrary", "loc", "torrent"}
|
|
table_type = provider_name
|
|
table_meta: Dict[str, Any] = {"provider": provider_name}
|
|
if provider_lower == "tidal":
|
|
view = self._get_tidal_view_from_query(query)
|
|
table_meta["view"] = view
|
|
table_type = f"tidal.{view}"
|
|
elif provider_lower == "internetarchive":
|
|
# Internet Archive search results are effectively folders (items); selecting @N
|
|
# should open a list of downloadable files for the chosen item.
|
|
table_type = "internetarchive.folder"
|
|
table = Table(table_title)._perseverance(preserve_order)
|
|
table.set_table(table_type)
|
|
if provider_lower == "alldebrid":
|
|
table_meta["view"] = "files" if effective_open_id is not None else "folders"
|
|
if effective_open_id is not None:
|
|
table_meta["magnet_id"] = effective_open_id
|
|
try:
|
|
table.set_table_metadata(table_meta)
|
|
except Exception:
|
|
pass
|
|
if provider_lower == "vimm":
|
|
# Keep auto-staged download-file from inheriting raw query tokens;
|
|
# only propagate provider hint so @N expands to a clean downloader call.
|
|
table.set_source_command("search-file", ["-provider", provider_name])
|
|
else:
|
|
table.set_source_command("search-file", list(args_list))
|
|
|
|
search_filters = dict(provider_filters)
|
|
debug(f"[search-file] Calling {provider_name}.search(filters={search_filters})")
|
|
if provider_lower == "alldebrid":
|
|
search_open_id = parsed_open_id if parsed_open_id is not None else open_id
|
|
view_value = "files" if search_open_id is not None else "folders"
|
|
search_filters["view"] = view_value
|
|
if search_open_id is not None:
|
|
search_filters["magnet_id"] = search_open_id
|
|
results = provider.search(query, limit=limit, filters=search_filters or None)
|
|
debug(f"[search-file] {provider_name} -> {len(results or [])} result(s)")
|
|
|
|
# Tidal artist UX: if there is exactly one artist match, auto-expand
|
|
# directly to albums without requiring an explicit @1 selection.
|
|
if (
|
|
provider_lower == "tidal"
|
|
and table_meta.get("view") == "artist"
|
|
and isinstance(results, list)
|
|
and len(results) == 1
|
|
):
|
|
try:
|
|
artist_res = results[0]
|
|
artist_name = str(getattr(artist_res, "title", "") or "").strip()
|
|
artist_md = getattr(artist_res, "full_metadata", None)
|
|
artist_id = None
|
|
if isinstance(artist_md, dict):
|
|
raw_id = artist_md.get("artistId") or artist_md.get("id")
|
|
try:
|
|
artist_id = int(raw_id) if raw_id is not None else None
|
|
except Exception:
|
|
artist_id = None
|
|
|
|
album_results = []
|
|
if hasattr(provider, "_albums_for_artist") and callable(getattr(provider, "_albums_for_artist")):
|
|
try:
|
|
album_results = provider._albums_for_artist( # type: ignore[attr-defined]
|
|
artist_id=artist_id,
|
|
artist_name=artist_name,
|
|
limit=max(int(limit or 0), 200),
|
|
)
|
|
except Exception:
|
|
album_results = []
|
|
|
|
if album_results:
|
|
results = album_results
|
|
table_type = "tidal.album"
|
|
table.set_table(table_type)
|
|
table_meta["view"] = "album"
|
|
try:
|
|
table.set_table_metadata(table_meta)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
if not results:
|
|
log(f"No results found for query: {query}", file=sys.stderr)
|
|
try:
|
|
append_worker_stdout(worker_id, json.dumps([], indent=2))
|
|
update_worker(worker_id, status="completed")
|
|
except Exception:
|
|
pass
|
|
return 0
|
|
|
|
for search_result in results:
|
|
item_dict = (
|
|
search_result.to_dict()
|
|
if hasattr(search_result, "to_dict")
|
|
else dict(search_result)
|
|
if isinstance(search_result, dict)
|
|
else {"title": str(search_result)}
|
|
)
|
|
|
|
if "table" not in item_dict:
|
|
item_dict["table"] = table_type
|
|
|
|
# Ensure provider source is present so downstream cmdlets (select) can resolve provider
|
|
if "source" not in item_dict:
|
|
item_dict["source"] = provider_name
|
|
|
|
row_index = len(table.rows)
|
|
table.add_result(search_result)
|
|
|
|
results_list.append(item_dict)
|
|
ctx.emit(item_dict)
|
|
|
|
if refresh_mode:
|
|
ctx.set_last_result_table_preserve_history(table, results_list)
|
|
else:
|
|
ctx.set_last_result_table(table, results_list)
|
|
|
|
ctx.set_current_stage_table(table)
|
|
|
|
try:
|
|
append_worker_stdout(worker_id, json.dumps(results_list, indent=2))
|
|
update_worker(worker_id, status="completed")
|
|
except Exception:
|
|
pass
|
|
|
|
return 0
|
|
|
|
except Exception as exc:
|
|
log(f"Error searching provider '{provider_name}': {exc}", file=sys.stderr)
|
|
import traceback
|
|
|
|
debug(traceback.format_exc())
|
|
try:
|
|
update_worker(worker_id, status="error")
|
|
except Exception:
|
|
pass
|
|
return 1
|
|
|
|
# --- Execution ------------------------------------------------------
|
|
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|
"""Search storage backends for files."""
|
|
if should_show_help(args):
|
|
log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}")
|
|
return 0
|
|
|
|
args_list = [str(arg) for arg in (args or [])]
|
|
|
|
refresh_mode = any(
|
|
str(a).strip().lower() in {"--refresh", "-refresh", "-internal-refresh"}
|
|
for a in args_list
|
|
)
|
|
|
|
def _format_command_title(command: str, raw_args: List[str]) -> str:
|
|
|
|
def _quote(value: str) -> str:
|
|
text = str(value)
|
|
if not text:
|
|
return '""'
|
|
needs_quotes = any(ch.isspace() for ch in text) or '"' in text
|
|
if not needs_quotes:
|
|
return text
|
|
return '"' + text.replace('"', '\\"') + '"'
|
|
|
|
cleaned = [
|
|
str(a) for a in (raw_args or [])
|
|
if str(a).strip().lower() not in {"--refresh", "-refresh", "-internal-refresh"}
|
|
]
|
|
if not cleaned:
|
|
return command
|
|
return " ".join([command, *[_quote(a) for a in cleaned]])
|
|
|
|
raw_title = None
|
|
try:
|
|
raw_title = (
|
|
ctx.get_current_stage_text("")
|
|
if hasattr(ctx,
|
|
"get_current_stage_text") else None
|
|
)
|
|
except Exception:
|
|
raw_title = None
|
|
|
|
command_title = (str(raw_title).strip() if raw_title else
|
|
"") or _format_command_title("search-file",
|
|
list(args_list))
|
|
|
|
# Build dynamic flag variants from cmdlet arg definitions.
|
|
# This avoids hardcoding flag spellings in parsing loops.
|
|
flag_registry = self.build_flag_registry()
|
|
query_flags = {
|
|
f.lower()
|
|
for f in (flag_registry.get("query") or {"-query", "--query"})
|
|
}
|
|
store_flags = {
|
|
f.lower()
|
|
for f in (flag_registry.get("store") or {"-store", "--store"})
|
|
}
|
|
limit_flags = {
|
|
f.lower()
|
|
for f in (flag_registry.get("limit") or {"-limit", "--limit"})
|
|
}
|
|
provider_flags = {
|
|
f.lower()
|
|
for f in (flag_registry.get("provider") or {"-provider", "--provider"})
|
|
}
|
|
open_flags = {
|
|
f.lower()
|
|
for f in (flag_registry.get("open") or {"-open", "--open"})
|
|
}
|
|
|
|
# Parse arguments
|
|
query = ""
|
|
storage_backend: Optional[str] = None
|
|
provider_name: Optional[str] = None
|
|
open_id: Optional[int] = None
|
|
limit = 100
|
|
limit_set = False
|
|
searched_backends: List[str] = []
|
|
|
|
i = 0
|
|
while i < len(args_list):
|
|
arg = args_list[i]
|
|
low = arg.lower()
|
|
if low in query_flags and i + 1 < len(args_list):
|
|
chunk = args_list[i + 1]
|
|
query = f"{query} {chunk}".strip() if query else chunk
|
|
i += 2
|
|
continue
|
|
if low in provider_flags and i + 1 < len(args_list):
|
|
provider_name = args_list[i + 1]
|
|
i += 2
|
|
continue
|
|
if low in open_flags and i + 1 < len(args_list):
|
|
try:
|
|
open_id = int(args_list[i + 1])
|
|
except ValueError:
|
|
log(
|
|
f"Warning: Invalid open value '{args_list[i + 1]}', ignoring",
|
|
file=sys.stderr,
|
|
)
|
|
open_id = None
|
|
i += 2
|
|
continue
|
|
if low in store_flags and i + 1 < len(args_list):
|
|
storage_backend = args_list[i + 1]
|
|
i += 2
|
|
elif low in limit_flags and i + 1 < len(args_list):
|
|
limit_set = True
|
|
try:
|
|
limit = int(args_list[i + 1])
|
|
except ValueError:
|
|
limit = 100
|
|
i += 2
|
|
elif not arg.startswith("-"):
|
|
query = f"{query} {arg}".strip() if query else arg
|
|
i += 1
|
|
else:
|
|
i += 1
|
|
|
|
query = query.strip()
|
|
|
|
if provider_name:
|
|
return self._run_provider_search(
|
|
provider_name=provider_name,
|
|
query=query,
|
|
limit=limit,
|
|
limit_set=limit_set,
|
|
open_id=open_id,
|
|
args_list=args_list,
|
|
refresh_mode=refresh_mode,
|
|
config=config,
|
|
)
|
|
|
|
store_filter: Optional[str] = None
|
|
if query:
|
|
match = re.search(r"\bstore:([^\s,]+)", query, flags=re.IGNORECASE)
|
|
if match:
|
|
store_filter = match.group(1).strip() or None
|
|
query = re.sub(r"\s*[,]?\s*store:[^\s,]+", " ", query, flags=re.IGNORECASE)
|
|
query = re.sub(r"\s{2,}", " ", query)
|
|
query = query.strip().strip(",")
|
|
|
|
if store_filter and not storage_backend:
|
|
storage_backend = store_filter
|
|
|
|
hash_query = parse_hash_query(query)
|
|
|
|
if not query:
|
|
log("Provide a search query", file=sys.stderr)
|
|
return 1
|
|
|
|
worker_id = str(uuid.uuid4())
|
|
|
|
from Store import Store
|
|
storage_registry = Store(config=config or {})
|
|
|
|
if not storage_registry.list_backends():
|
|
# Internal refreshes should not trigger config panels or stop progress.
|
|
if "-internal-refresh" in args_list:
|
|
return 1
|
|
|
|
from SYS import pipeline as ctx_mod
|
|
progress = None
|
|
if hasattr(ctx_mod, "get_pipeline_state"):
|
|
progress = ctx_mod.get_pipeline_state().live_progress
|
|
if progress:
|
|
try:
|
|
progress.stop()
|
|
except Exception:
|
|
pass
|
|
show_store_config_panel(["Hydrus Network"])
|
|
return 1
|
|
|
|
# Use a lightweight worker logger to track search results in the central DB
|
|
with _WorkerLogger(worker_id) as db:
|
|
try:
|
|
if "-internal-refresh" not in args_list:
|
|
db.insert_worker(
|
|
worker_id,
|
|
"search-file",
|
|
title=f"Search: {query}",
|
|
description=f"Query: {query}",
|
|
pipe=ctx.get_current_command_text(),
|
|
)
|
|
|
|
results_list = []
|
|
from SYS.result_table import Table
|
|
|
|
table = Table(command_title)
|
|
try:
|
|
table.set_source_command("search-file", list(args_list))
|
|
except Exception:
|
|
pass
|
|
if hash_query:
|
|
try:
|
|
table._perseverance(True)
|
|
except Exception:
|
|
pass
|
|
|
|
from Store.registry import list_configured_backend_names, get_backend_instance
|
|
from Store._base import Store as BaseStore
|
|
|
|
backend_to_search = storage_backend or None
|
|
if hash_query:
|
|
# Explicit hash list search: build rows from backend metadata.
|
|
backends_to_try: List[str] = []
|
|
if backend_to_search:
|
|
backends_to_try = [backend_to_search]
|
|
else:
|
|
backends_to_try = list_configured_backend_names(config or {})
|
|
|
|
found_any = False
|
|
for h in hash_query:
|
|
resolved_backend_name: Optional[str] = None
|
|
resolved_backend = None
|
|
|
|
for backend_name in backends_to_try:
|
|
backend = None
|
|
try:
|
|
backend = get_backend_instance(config, backend_name, suppress_debug=True)
|
|
if backend is None:
|
|
# Last-resort: instantiate full registry for this backend only
|
|
from Store import Store as _Store
|
|
_store = _Store(config=config)
|
|
backend = _store[backend_name]
|
|
except Exception:
|
|
backend = None
|
|
if backend is None:
|
|
continue
|
|
try:
|
|
# If get_metadata works, consider it a hit; get_file can be optional (e.g. remote URL).
|
|
meta = backend.get_metadata(h)
|
|
if meta is None:
|
|
continue
|
|
resolved_backend_name = backend_name
|
|
resolved_backend = backend
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
if resolved_backend_name is None or resolved_backend is None:
|
|
continue
|
|
|
|
found_any = True
|
|
searched_backends.append(resolved_backend_name)
|
|
|
|
# Resolve a path/URL string if possible
|
|
path_str: Optional[str] = None
|
|
# Avoid calling get_file() for remote backends during search/refresh.
|
|
|
|
meta_obj: Dict[str,
|
|
Any] = {}
|
|
try:
|
|
meta_obj = resolved_backend.get_metadata(h) or {}
|
|
except Exception:
|
|
meta_obj = {}
|
|
|
|
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 = []
|
|
|
|
title_from_tag: Optional[str] = None
|
|
try:
|
|
title_tag = first_title_tag(tags_list)
|
|
if title_tag and ":" in title_tag:
|
|
title_from_tag = title_tag.split(":", 1)[1].strip()
|
|
except Exception:
|
|
title_from_tag = None
|
|
|
|
title = title_from_tag or meta_obj.get("title") or meta_obj.get(
|
|
"name"
|
|
)
|
|
if not title and path_str:
|
|
try:
|
|
title = Path(path_str).stem
|
|
except Exception:
|
|
title = path_str
|
|
|
|
ext_val = meta_obj.get("ext") or meta_obj.get("extension")
|
|
if not ext_val and path_str:
|
|
try:
|
|
ext_val = Path(path_str).suffix
|
|
except Exception:
|
|
ext_val = None
|
|
if not ext_val and title:
|
|
try:
|
|
ext_val = Path(str(title)).suffix
|
|
except Exception:
|
|
ext_val = None
|
|
|
|
size_bytes = meta_obj.get("size")
|
|
if size_bytes is None:
|
|
size_bytes = meta_obj.get("size_bytes")
|
|
try:
|
|
size_bytes_int: Optional[int] = (
|
|
int(size_bytes) if size_bytes is not None else None
|
|
)
|
|
except Exception:
|
|
size_bytes_int = None
|
|
|
|
payload: Dict[str,
|
|
Any] = {
|
|
"title": str(title or h),
|
|
"hash": h,
|
|
"store": resolved_backend_name,
|
|
"path": path_str,
|
|
"ext": self._normalize_extension(ext_val),
|
|
"size_bytes": size_bytes_int,
|
|
"tag": tags_list,
|
|
"url": meta_obj.get("url") or [],
|
|
}
|
|
|
|
table.add_result(payload)
|
|
results_list.append(payload)
|
|
ctx.emit(payload)
|
|
|
|
if found_any:
|
|
table.title = command_title
|
|
|
|
# Add-file refresh quality-of-life: if exactly 1 item is being refreshed,
|
|
# show the detailed item panel instead of a single-row table.
|
|
if refresh_mode and len(results_list) == 1:
|
|
try:
|
|
from SYS.rich_display import render_item_details_panel
|
|
render_item_details_panel(results_list[0])
|
|
table._rendered_by_cmdlet = True
|
|
except Exception:
|
|
pass
|
|
|
|
if refresh_mode:
|
|
ctx.set_last_result_table_preserve_history(
|
|
table,
|
|
results_list
|
|
)
|
|
else:
|
|
ctx.set_last_result_table(table, results_list)
|
|
db.append_worker_stdout(
|
|
worker_id,
|
|
json.dumps(results_list,
|
|
indent=2)
|
|
)
|
|
db.update_worker_status(worker_id, "completed")
|
|
return 0
|
|
|
|
log("No results found", file=sys.stderr)
|
|
if refresh_mode:
|
|
try:
|
|
table.title = command_title
|
|
ctx.set_last_result_table_preserve_history(table, [])
|
|
except Exception:
|
|
pass
|
|
db.append_worker_stdout(worker_id, json.dumps([], indent=2))
|
|
db.update_worker_status(worker_id, "completed")
|
|
return 0
|
|
|
|
if backend_to_search:
|
|
searched_backends.append(backend_to_search)
|
|
try:
|
|
target_backend = get_backend_instance(config, backend_to_search, suppress_debug=True)
|
|
if target_backend is None:
|
|
from Store import Store as _Store
|
|
_store = _Store(config=config)
|
|
target_backend = _store[backend_to_search]
|
|
except Exception as exc:
|
|
log(f"Backend '{backend_to_search}' not found: {exc}", file=sys.stderr)
|
|
db.update_worker_status(worker_id, "error")
|
|
return 1
|
|
|
|
if type(target_backend).search is BaseStore.search:
|
|
log(
|
|
f"Backend '{backend_to_search}' does not support searching",
|
|
file=sys.stderr,
|
|
)
|
|
db.update_worker_status(worker_id, "error")
|
|
return 1
|
|
debug(f"[search-file] Searching '{backend_to_search}'")
|
|
results = target_backend.search(query, limit=limit)
|
|
debug(
|
|
f"[search-file] '{backend_to_search}' -> {len(results or [])} result(s)"
|
|
)
|
|
else:
|
|
all_results = []
|
|
for backend_name in list_configured_backend_names(config or {}):
|
|
try:
|
|
backend = get_backend_instance(config, backend_name, suppress_debug=True)
|
|
if backend is None:
|
|
from Store import Store as _Store
|
|
_store = _Store(config=config)
|
|
backend = _store[backend_name]
|
|
|
|
searched_backends.append(backend_name)
|
|
|
|
if type(backend).search is BaseStore.search:
|
|
continue
|
|
|
|
debug(f"[search-file] Searching '{backend_name}'")
|
|
backend_results = backend.search(
|
|
query,
|
|
limit=limit - len(all_results)
|
|
)
|
|
debug(
|
|
f"[search-file] '{backend_name}' -> {len(backend_results or [])} result(s)"
|
|
)
|
|
if backend_results:
|
|
all_results.extend(backend_results)
|
|
if len(all_results) >= limit:
|
|
break
|
|
except Exception as exc:
|
|
log(
|
|
f"Backend {backend_name} search failed: {exc}",
|
|
file=sys.stderr
|
|
)
|
|
results = all_results[:limit]
|
|
|
|
if results:
|
|
for item in results:
|
|
|
|
def _as_dict(obj: Any) -> Dict[str, Any]:
|
|
if isinstance(obj, dict):
|
|
return dict(obj)
|
|
if hasattr(obj,
|
|
"to_dict") and callable(getattr(obj,
|
|
"to_dict")):
|
|
return obj.to_dict() # type: ignore[arg-type]
|
|
return {
|
|
"title": str(obj)
|
|
}
|
|
|
|
item_dict = _as_dict(item)
|
|
if store_filter:
|
|
store_val = str(item_dict.get("store") or "").lower()
|
|
if store_filter != store_val:
|
|
continue
|
|
|
|
# Normalize storage results (ensure title, ext, etc.)
|
|
normalized = self._ensure_storage_columns(item_dict)
|
|
|
|
# If normalize skipped it due to STORAGE_ORIGINS, do it manually
|
|
if "title" not in normalized:
|
|
normalized["title"] = (
|
|
item_dict.get("title") or item_dict.get("name") or
|
|
item_dict.get("path") or item_dict.get("target") or "Result"
|
|
)
|
|
if "ext" not in normalized:
|
|
t = str(normalized.get("title", ""))
|
|
if "." in t:
|
|
normalized["ext"] = t.split(".")[-1].lower()[:5]
|
|
|
|
# Make hash/store available for downstream cmdlet without rerunning search
|
|
hash_val = normalized.get("hash")
|
|
store_val = normalized.get("store") or item_dict.get("store") or backend_to_search
|
|
if hash_val and not normalized.get("hash"):
|
|
normalized["hash"] = hash_val
|
|
if store_val and not normalized.get("store"):
|
|
normalized["store"] = store_val
|
|
|
|
# Populate default selection args for interactive @N selection/hash/url handling
|
|
try:
|
|
sel_args: Optional[List[str]] = None
|
|
sel_action: Optional[List[str]] = None
|
|
|
|
# Prefer explicit path when available
|
|
p_val = normalized.get("path") or normalized.get("target") or normalized.get("url")
|
|
if p_val:
|
|
p_str = str(p_val or "").strip()
|
|
if p_str:
|
|
if p_str.startswith(("http://", "https://", "magnet:", "torrent:")):
|
|
sel_args = ["-url", p_str]
|
|
sel_action = ["download-file", "-url", p_str]
|
|
else:
|
|
try:
|
|
from SYS.utils import expand_path
|
|
|
|
full_path = expand_path(p_str)
|
|
# Prefer showing metadata details when we have a hash+store context
|
|
h = normalized.get("hash") or normalized.get("file_hash") or normalized.get("hash_hex")
|
|
s_val = normalized.get("store")
|
|
if h and s_val:
|
|
try:
|
|
h_norm = normalize_hash(h)
|
|
except Exception:
|
|
h_norm = str(h)
|
|
sel_args = ["-query", f"hash:{h_norm}", "-store", str(s_val)]
|
|
sel_action = ["get-metadata", "-query", f"hash:{h_norm}", "-store", str(s_val)]
|
|
else:
|
|
sel_args = ["-path", str(full_path)]
|
|
# Default action for local paths: get-file to fetch or operate on the path
|
|
sel_action = ["get-file", "-path", str(full_path)]
|
|
except Exception:
|
|
sel_args = ["-path", p_str]
|
|
sel_action = ["get-file", "-path", p_str]
|
|
|
|
# Fallback: use hash+store when available
|
|
if sel_args is None:
|
|
h = normalized.get("hash") or normalized.get("file_hash") or normalized.get("hash_hex")
|
|
s_val = normalized.get("store")
|
|
if h and s_val:
|
|
try:
|
|
h_norm = normalize_hash(h)
|
|
except Exception:
|
|
h_norm = str(h)
|
|
sel_args = ["-query", f"hash:{h_norm}", "-store", str(s_val)]
|
|
# Show metadata details by default for store/hash selections
|
|
sel_action = ["get-metadata", "-query", f"hash:{h_norm}", "-store", str(s_val)]
|
|
|
|
if sel_args:
|
|
normalized["_selection_args"] = [str(x) for x in sel_args]
|
|
if sel_action:
|
|
normalized["_selection_action"] = [str(x) for x in sel_action]
|
|
except Exception:
|
|
pass
|
|
|
|
table.add_result(normalized)
|
|
|
|
results_list.append(normalized)
|
|
ctx.emit(normalized)
|
|
|
|
table.title = command_title
|
|
|
|
# If exactly 1 item is being refreshed, show the detailed item panel.
|
|
if refresh_mode and len(results_list) == 1:
|
|
try:
|
|
from SYS.rich_display import render_item_details_panel
|
|
render_item_details_panel(results_list[0])
|
|
table._rendered_by_cmdlet = True
|
|
except Exception:
|
|
pass
|
|
|
|
if refresh_mode:
|
|
# For internal refresh, use overlay mode to avoid adding to history
|
|
try:
|
|
# Parse out the store/hash context if possible
|
|
subject_context = None
|
|
if "hash:" in query:
|
|
subject_hash = query.split("hash:")[1].split(",")[0].strip()
|
|
subject_context = {"store": backend_to_search, "hash": subject_hash}
|
|
|
|
ctx.set_last_result_table_overlay(table, results_list, subject=subject_context)
|
|
except Exception:
|
|
ctx.set_last_result_table_preserve_history(table, results_list)
|
|
else:
|
|
ctx.set_last_result_table(table, results_list)
|
|
db.append_worker_stdout(
|
|
worker_id,
|
|
json.dumps(results_list,
|
|
indent=2)
|
|
)
|
|
else:
|
|
log("No results found", file=sys.stderr)
|
|
if refresh_mode:
|
|
try:
|
|
table.title = command_title
|
|
ctx.set_last_result_table_preserve_history(table, [])
|
|
except Exception:
|
|
pass
|
|
db.append_worker_stdout(worker_id, json.dumps([], indent=2))
|
|
|
|
db.update_worker_status(worker_id, "completed")
|
|
return 0
|
|
|
|
except Exception as exc:
|
|
log(f"Search failed: {exc}", file=sys.stderr)
|
|
import traceback
|
|
|
|
traceback.print_exc(file=sys.stderr)
|
|
try:
|
|
db.update_worker_status(worker_id, "error")
|
|
except Exception:
|
|
pass
|
|
return 1
|
|
|
|
|
|
CMDLET = search_file()
|