from __future__ import annotations from pathlib import Path from typing import Callable, Optional import sys import requests from models import ProgressBar def sanitize_filename(name: str, *, max_len: int = 150) -> str: text = str(name or "").strip() if not text: return "download" forbidden = set('<>:"/\\|?*') cleaned = "".join("_" if c in forbidden else c for c in text) cleaned = " ".join(cleaned.split()).strip().strip(".") if not cleaned: cleaned = "download" return cleaned[:max_len] def download_file( url: str, output_path: Path, *, session: Optional[requests.Session] = None, timeout_s: float = 30.0, progress_callback: Optional[Callable[[int, Optional[int], str], None]] = None, ) -> bool: output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) s = session or requests.Session() bar = ProgressBar() if progress_callback is None else None downloaded = 0 total = None try: with s.get(url, stream=True, timeout=timeout_s) as resp: resp.raise_for_status() try: total_val = int(resp.headers.get("content-length") or 0) total = total_val if total_val > 0 else None except Exception: total = None label = str(output_path.name or "download") # Render once immediately so fast downloads still show something. try: if progress_callback is not None: progress_callback(0, total, label) elif bar is not None: bar.update(downloaded=0, total=total, label=label, file=sys.stderr) except Exception: pass with open(output_path, "wb") as f: for chunk in resp.iter_content(chunk_size=1024 * 256): if chunk: f.write(chunk) downloaded += len(chunk) try: if progress_callback is not None: progress_callback(downloaded, total, label) elif bar is not None: bar.update( downloaded=downloaded, total=total, label=label, file=sys.stderr ) except Exception: pass try: if bar is not None: bar.finish() except Exception: pass return output_path.exists() and output_path.stat().st_size > 0 except Exception: try: if bar is not None: bar.finish() except Exception: pass try: if output_path.exists(): output_path.unlink() except Exception: pass return False