syntax revamp
This commit is contained in:
+317
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user