syntax revamp

This commit is contained in:
2026-05-24 12:32:57 -07:00
parent 6c0a1b4415
commit 5041d9fbb9
20 changed files with 1512 additions and 1060 deletions
+317 -3
View File
@@ -15,6 +15,8 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
from urllib.parse import urlparse
from contextlib import AbstractContextManager, nullcontext
import shutil
import webbrowser
from API.HTTP import _download_direct_file
@@ -25,6 +27,7 @@ from SYS.pipeline_progress import PipelineProgress
from SYS.result_table import Table, build_display_row
from SYS.rich_display import stderr_console as get_stderr_console
from SYS import pipeline as pipeline_context
from SYS.item_accessors import get_result_title
from rich.prompt import Prompt
# SYS.metadata import deferred: normalize_urls loaded lazily at call site to avoid
# pulling in Cryptodome (~900ms) at module import time.
@@ -64,7 +67,7 @@ class Download_File(Cmdlet):
name="download-file",
summary="Download files or streaming media",
usage=
"download-file <url> [-plugin NAME] [-instance NAME] [-path DIR] [options] OR @N | download-file [-plugin NAME] [-instance NAME] [-path DIR] [options]",
"download-file <url|path> [-plugin NAME] [-instance NAME] [-path DIR] [options] OR @N | download-file [-plugin NAME] [-instance NAME] [-path DIR] [options] OR download-file -query \"hash:<sha256>\" -instance <store> [-browser]",
alias=["dl-file",
"download-http"],
arg=[
@@ -73,6 +76,16 @@ class Download_File(Cmdlet):
SharedArgs.INSTANCE,
SharedArgs.PATH,
SharedArgs.QUERY,
CmdletArg(
name="name",
type="string",
description="Output filename override for store exports.",
),
CmdletArg(
name="browser",
type="flag",
description="Open a backend-provided browser URL instead of exporting to disk when available.",
),
QueryArg(
"clip",
key="clip",
@@ -95,6 +108,7 @@ class Download_File(Cmdlet):
],
detail=[
"Download files directly via HTTP or streaming media via yt-dlp.",
"Also exports store-backed files via hash+store selection or -query \"hash:<sha256>\" -instance <store>.",
"Use -plugin with -instance to target a named provider config when a plugin exposes multiple instances.",
"For Internet Archive item pages (archive.org/details/...), shows a selectable file/format list; pick with @N to download.",
],
@@ -924,6 +938,283 @@ class Download_File(Cmdlet):
pipeline_context.emit(payload)
@staticmethod
def _path_looks_local(value: Any) -> bool:
text = str(value or "").strip()
if not text:
return False
if text.startswith(("http://", "https://", "ftp://", "ftps://", "magnet:", "torrent:")):
return False
if len(text) >= 2 and text[1] == ":":
return True
if text.startswith(("\\", "/", ".", "~")):
return True
return Path(text).exists()
@staticmethod
def _resolve_display_title(result: Any, metadata: Optional[Dict[str, Any]]) -> str:
candidates = [
get_result_title(result, "title", "name", "filename"),
get_result_title(metadata or {}, "title", "name", "filename"),
]
for candidate in candidates:
if candidate is None:
continue
text = str(candidate).strip()
if text:
return text
return ""
@staticmethod
def _sanitize_export_filename(name: str) -> str:
allowed_chars: List[str] = []
for ch in str(name or ""):
if ch.isalnum() or ch in {"-", "_", " ", "."}:
allowed_chars.append(ch)
else:
allowed_chars.append(" ")
sanitized = " ".join("".join(allowed_chars).split())
return sanitized or "export"
@staticmethod
def _unique_export_path(path: Path) -> Path:
if not path.exists():
return path
stem = path.stem
suffix = path.suffix
parent = path.parent
counter = 1
while True:
candidate = parent / f"{stem} ({counter}){suffix}"
if not candidate.exists():
return candidate
counter += 1
@staticmethod
def _iter_storage_export_refs(
parsed: Dict[str, Any],
piped_items: Sequence[Any],
) -> tuple[List[Dict[str, Any]], List[Any], Optional[int]]:
refs: List[Dict[str, Any]] = []
residual_items: List[Any] = []
query_text = str(parsed.get("query") or "").strip()
query_hash: Optional[str] = None
if query_text:
query_hash = sh.parse_single_hash_query(query_text)
if query_text.lower().startswith("hash") and not query_hash:
log('Error: -query must be of the form hash:<sha256>', file=sys.stderr)
return [], list(piped_items or []), 1
explicit_store = str(parsed.get("instance") or "").strip()
if query_hash:
if not explicit_store:
log('Error: No store name provided', file=sys.stderr)
return [], list(piped_items or []), 1
refs.append(
{
"hash": query_hash,
"store": explicit_store,
"result": None,
}
)
for item in piped_items or []:
normalized_hash = sh.normalize_hash(
str(get_field(item, "hash") or get_field(item, "file_hash") or get_field(item, "hash_hex") or "")
)
store_name = str(parsed.get("instance") or get_field(item, "store") or "").strip()
if normalized_hash and store_name:
refs.append(
{
"hash": normalized_hash,
"store": store_name,
"result": item,
}
)
else:
residual_items.append(item)
return refs, residual_items, None
def _export_store_file(
self,
*,
file_hash: str,
store_name: str,
result: Any,
parsed: Dict[str, Any],
config: Dict[str, Any],
final_output_dir: Path,
) -> int:
output_path = parsed.get("path")
explicit_output_requested = bool(output_path)
output_name = parsed.get("name")
browser_flag = bool(parsed.get("browser"))
backend, _store_registry, _exc = sh.get_preferred_store_backend(
config,
store_name,
suppress_debug=True,
)
if backend is None:
log(f"Error: Storage backend '{store_name}' not found", file=sys.stderr)
return 1
metadata = backend.get_metadata(file_hash)
if not metadata:
log(f"Error: File metadata not found for hash {file_hash}", file=sys.stderr)
return 1
try:
debug_panel(
"download-file store export",
[
("hash", file_hash),
("instance", store_name),
("output_path", output_path or "<default>"),
("output_name", output_name or "<auto>"),
("browser", browser_flag),
],
border_style="blue",
)
except Exception:
pass
want_url = browser_flag
source_path = backend.get_file(file_hash, url=want_url)
download_url = None
if isinstance(source_path, str):
if source_path.startswith(("http://", "https://")):
download_url = source_path
else:
source_path = Path(source_path)
if download_url and (browser_flag or not explicit_output_requested):
try:
webbrowser.open(download_url)
except Exception as exc:
log(f"Error opening browser: {exc}", file=sys.stderr)
return 1
pipeline_context.emit(
build_file_result_payload(
title=self._resolve_display_title(result, metadata) or "Opened",
hash_value=file_hash,
store=store_name,
url=download_url,
)
)
return 0
if download_url is None:
if not source_path or not Path(source_path).exists():
log(f"Error: Backend could not retrieve file for hash {file_hash}", file=sys.stderr)
return 1
filename = str(output_name or "").strip()
if not filename:
title = (metadata.get("title") if isinstance(metadata, dict) else None) or self._resolve_display_title(result, metadata) or "export"
filename = self._sanitize_export_filename(str(title))
ext = metadata.get("ext") if isinstance(metadata, dict) else None
if ext and not filename.endswith(str(ext)):
ext_text = str(ext)
if not ext_text.startswith("."):
ext_text = "." + ext_text
filename += ext_text
if download_url:
result_obj = _download_direct_file(
download_url,
final_output_dir,
quiet=True,
suggested_filename=filename,
pipeline_progress=config.get("_pipeline_progress") if isinstance(config, dict) else None,
)
dest_path = self._path_from_download_result(result_obj)
else:
dest_path = self._unique_export_path(final_output_dir / filename)
shutil.copy2(Path(source_path), dest_path)
pipeline_context.emit(
build_file_result_payload(
title=filename,
hash_value=file_hash,
store=store_name,
path=str(dest_path),
)
)
return 0
def _process_storage_items(
self,
*,
piped_items: Sequence[Any],
parsed: Dict[str, Any],
config: Dict[str, Any],
final_output_dir: Path,
) -> tuple[int, List[Any], Optional[int]]:
refs, residual_items, early_exit = self._iter_storage_export_refs(parsed, piped_items)
if early_exit is not None:
return 0, list(residual_items), early_exit
if not refs:
return 0, list(residual_items), None
successes = 0
for ref in refs:
exit_code = self._export_store_file(
file_hash=str(ref.get("hash") or ""),
store_name=str(ref.get("store") or ""),
result=ref.get("result"),
parsed=parsed,
config=config,
final_output_dir=final_output_dir,
)
if exit_code != 0:
return successes, list(residual_items), exit_code
successes += 1
return successes, list(residual_items), None
def _process_explicit_local_sources(
self,
*,
local_sources: Sequence[str],
final_output_dir: Path,
parsed: Dict[str, Any],
progress: PipelineProgress,
config: Dict[str, Any],
) -> int:
explicit_output_requested = bool(parsed.get("path"))
downloaded_count = 0
for raw_source in local_sources or []:
source_path = Path(str(raw_source or "")).expanduser()
if not source_path.exists() or not source_path.is_file():
log(f"File not found: {source_path}", file=sys.stderr)
continue
if explicit_output_requested:
destination = final_output_dir / source_path.name
destination = self._unique_export_path(destination)
shutil.copy2(source_path, destination)
emit_path = destination
else:
emit_path = source_path
self._emit_local_file(
downloaded_path=emit_path,
source=str(source_path),
title_hint=emit_path.stem,
tags_hint=None,
media_kind_hint="file",
full_metadata=None,
progress=progress,
config=config,
)
downloaded_count += 1
return downloaded_count
def _maybe_render_download_details(self, *, config: Dict[str, Any]) -> None:
try:
stage_ctx = pipeline_context.get_stage_context()
@@ -2377,6 +2668,7 @@ class Download_File(Cmdlet):
parsed = parse_cmdlet_args(args, self)
registry = self._load_provider_registry()
selection_url_prefixes = self._selection_url_prefixes(registry)
explicit_input = parsed.get("url")
# Resolve URLs from -url or positional arguments
url_candidates = parsed.get("url") or [
@@ -2389,6 +2681,9 @@ class Download_File(Cmdlet):
]
from SYS.metadata import normalize_urls as normalize_url_list # lazy: avoids Cryptodome at startup
raw_url = normalize_url_list(url_candidates)
local_source_inputs: List[str] = []
if not raw_url and isinstance(explicit_input, str) and self._path_looks_local(explicit_input):
local_source_inputs = [str(explicit_input)]
quiet_mode = bool(config.get("_quiet_background_output")) if isinstance(config, dict) else False
@@ -2608,7 +2903,7 @@ class Download_File(Cmdlet):
raw_url = []
piped_items = self._collect_piped_items_if_no_urls(result, raw_url)
if not raw_url and not piped_items:
if not raw_url and not piped_items and not local_source_inputs:
log("No url or piped items to download", file=sys.stderr)
return 1
@@ -2673,8 +2968,27 @@ class Download_File(Cmdlet):
downloaded_count = 0
if local_source_inputs:
downloaded_count += self._process_explicit_local_sources(
local_sources=local_source_inputs,
final_output_dir=final_output_dir,
parsed=parsed,
progress=progress,
config=config,
)
storage_downloaded, piped_items, storage_exit = self._process_storage_items(
piped_items=piped_items,
parsed=parsed,
config=config,
final_output_dir=final_output_dir,
)
downloaded_count += int(storage_downloaded)
if storage_exit is not None:
return int(storage_exit)
if skipped_dupe_count and not raw_url and not piped_items:
return 0
return 0 if downloaded_count > 0 else 0
urls_downloaded, early_exit = self._process_explicit_urls(
raw_urls=raw_url,