From e6b0191b2649febd73e534837e15a4b1653b68e8 Mon Sep 17 00:00:00 2001 From: Jan Meyer Date: Tue, 10 Feb 2026 20:36:32 +0100 Subject: [PATCH] feat: add preflight --- requirements.txt | 6 ++ ripper/cli.py | 20 ++++ ripper/preflight.py | 231 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 requirements.txt create mode 100644 ripper/preflight.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9ca1c2d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +# ripper — DVD/Blu-ray ripper +# Required +rich>=13.0 + +# Optional (for NFO generation with media metadata) +pymediainfo>=6.0 diff --git a/ripper/cli.py b/ripper/cli.py index 3aa54b6..6e159a0 100644 --- a/ripper/cli.py +++ b/ripper/cli.py @@ -91,6 +91,11 @@ def main(): action="store_true", help="Check disc readability and attempt recovery if stuck.", ) + parser.add_argument( + "--check-deps", + action="store_true", + help="Verify all dependencies are installed and show install hints.", + ) parser.add_argument( "--init-config", action="store_true", @@ -101,6 +106,12 @@ def main(): # Quality: CLI flag > config default quality = args.quality if args.quality is not None else QUALITY + # ── Dependency check ───────────────────────────────────────────────── + if args.check_deps: + from .preflight import check_all + ok = check_all(verbose=True) + sys.exit(0 if ok else 1) + # ── Generate config file ───────────────────────────────────────────── if args.init_config: init_config() @@ -123,6 +134,15 @@ def main(): sys.exit(1) return + # ── Quick dependency check (HandBrakeCLI must be available) ────────── + import shutil + if not shutil.which("HandBrakeCLI"): + console.print( + "\n [bold red]✗ HandBrakeCLI not found on PATH[/]\n" + " [dim]Run with --check-deps for install instructions.[/]\n" + ) + sys.exit(1) + # ── Banner ─────────────────────────────────────────────────────────── codec_display = CODEC_DISPLAY.get(args.codec, args.codec.upper()) console.print() diff --git a/ripper/preflight.py b/ripper/preflight.py new file mode 100644 index 0000000..51fbcc6 --- /dev/null +++ b/ripper/preflight.py @@ -0,0 +1,231 @@ +"""Pre-flight dependency and environment checks. + +Verifies that all required and optional tools are available before +starting a rip, with platform-specific install instructions. +""" + +import importlib +import shutil +import subprocess +import sys + + +def _check_python_version() -> tuple[bool, str]: + """Check Python >= 3.11 (needed for tomllib).""" + v = sys.version_info + ok = v >= (3, 11) + detail = f"Python {v.major}.{v.minor}.{v.micro}" + if not ok: + detail += " — Python 3.11+ required (for tomllib)" + return ok, detail + + +def _check_package(name: str, pip_name: str | None = None) -> tuple[bool, str]: + """Check if a Python package is importable.""" + try: + mod = importlib.import_module(name) + version = getattr(mod, "__version__", "installed") + return True, f"{name} ({version})" + except ImportError: + install = pip_name or name + return False, f"{name} — install with: pip install {install}" + + +def _check_binary(name: str, version_flag: str = "--version") -> tuple[bool, str]: + """Check if a binary is on PATH and get its version.""" + path = shutil.which(name) + if not path: + return False, f"{name} — not found on PATH" + try: + result = subprocess.run( + [path, version_flag], + capture_output=True, text=True, timeout=10, + ) + # HandBrakeCLI outputs version on stderr + output = (result.stdout + result.stderr).strip().splitlines() + version_line = "" + for line in output: + if "version" in line.lower() or "handbrake" in line.lower(): + version_line = line.strip() + break + if not version_line and output: + version_line = output[0].strip() + return True, f"{name} — {version_line}" if version_line else f"{name} ({path})" + except (subprocess.TimeoutExpired, OSError): + return True, f"{name} ({path})" + + +def _windows_install_hint(tool: str) -> str: + """Return a Windows-specific install hint.""" + hints = { + "HandBrakeCLI": ( + " Download from https://handbrake.fr/downloads2.php\n" + " Or install via: winget install HandBrake.HandBrakeCLI\n" + " Or: choco install handbrake-cli" + ), + "rich": " pip install rich", + "pymediainfo": ( + " pip install pymediainfo\n" + " Also requires MediaInfo DLL: https://mediaarea.net/en/MediaInfo/Download/Windows" + ), + } + return hints.get(tool, f" pip install {tool}") + + +def _linux_install_hint(tool: str) -> str: + """Return a Linux-specific install hint.""" + hints = { + "HandBrakeCLI": ( + " Ubuntu/Debian: sudo add-apt-repository ppa:stebbins/handbrake-releases && " + "sudo apt install handbrake-cli\n" + " Fedora: sudo dnf install HandBrakeCLI\n" + " Arch: sudo pacman -S handbrake-cli\n" + " Flatpak: flatpak install fr.handbrake.ghb" + ), + "rich": " pip install rich", + "pymediainfo": ( + " pip install pymediainfo\n" + " Also install: sudo apt install libmediainfo0v5 (or equivalent)" + ), + "eject": " Usually pre-installed. Ubuntu/Debian: sudo apt install eject", + } + return hints.get(tool, f" pip install {tool}") + + +def _macos_install_hint(tool: str) -> str: + """Return a macOS-specific install hint.""" + hints = { + "HandBrakeCLI": ( + " brew install --cask handbrake\n" + " Or download from https://handbrake.fr/downloads2.php" + ), + "rich": " pip install rich", + "pymediainfo": ( + " pip install pymediainfo\n" + " Also install: brew install media-info" + ), + } + return hints.get(tool, f" pip install {tool}") + + +def _get_install_hint(tool: str) -> str: + """Return a platform-appropriate install hint.""" + if sys.platform == "win32": + return _windows_install_hint(tool) + elif sys.platform == "darwin": + return _macos_install_hint(tool) + return _linux_install_hint(tool) + + +def check_all(verbose: bool = True) -> bool: + """ + Run all pre-flight checks and optionally print results. + + Returns True if all required dependencies are met. + """ + # Lazy import to avoid circular deps — console may not be available + # if rich itself is missing + try: + from rich.console import Console + from rich.table import Table + from rich import box + console = Console(stderr=True) + has_rich = True + except ImportError: + has_rich = False + + checks: list[tuple[str, bool, str, bool]] = [] # (label, ok, detail, required) + + # Python version + ok, detail = _check_python_version() + checks.append(("Python ≥ 3.11", ok, detail, True)) + + # Required Python packages + ok, detail = _check_package("rich") + checks.append(("rich", ok, detail, True)) + + # Optional Python packages + ok, detail = _check_package("pymediainfo") + checks.append(("pymediainfo", ok, detail, False)) + + # Required binaries + ok, detail = _check_binary("HandBrakeCLI", "--version") + checks.append(("HandBrakeCLI", ok, detail, True)) + + # Optional binaries (platform-specific) + if sys.platform == "win32": + # PowerShell is always available on Windows + ok, detail = _check_binary("powershell", "-Command \"echo ok\"") + checks.append(("PowerShell", ok, detail, False)) + elif sys.platform == "darwin": + ok, detail = _check_binary("drutil", "status") + checks.append(("drutil", ok, detail, False)) + else: + ok, detail = _check_binary("eject", "--version") + checks.append(("eject", ok, detail, False)) + + # Print results + all_required_ok = all(ok for _, ok, _, req in checks if req) + + if verbose and has_rich: + from rich.text import Text + + table = Table( + title="Dependency Check", + box=box.ROUNDED, + title_style="bold cyan", + header_style="bold", + padding=(0, 1), + ) + table.add_column("", width=3, justify="center") + table.add_column("Dependency", style="white") + table.add_column("Status") + table.add_column("Required", justify="center", width=8) + + for label, ok, detail, required in checks: + icon = "[green]✓[/]" if ok else ("[red]✗[/]" if required else "[yellow]–[/]") + status_style = "" if ok else ("red" if required else "yellow") + req_tag = "[bold]yes[/]" if required else "[dim]no[/]" + # Use Text() to avoid rich markup interpretation of version strings + status_text = Text(detail, style=status_style) + table.add_row(icon, label, status_text, req_tag) + + console.print() + console.print(table) + console.print() + + # Print install hints for missing items + missing = [(label, req) for label, ok, _, req in checks if not ok] + if missing: + console.print("[bold]Install instructions:[/]\n") + for label, required in missing: + tag = "[red]REQUIRED[/]" if required else "[yellow]optional[/]" + console.print(f" {tag} [bold]{label}[/]") + console.print(_get_install_hint(label)) + console.print() + + if all_required_ok: + console.print(" [green]✓ All required dependencies are installed.[/]\n") + else: + console.print( + " [bold red]✗ Some required dependencies are missing.[/]\n" + " [dim]Install them and run this check again.[/]\n" + ) + + elif verbose: + # Fallback if rich not available + print("\n=== Dependency Check ===\n") + for label, ok, detail, required in checks: + icon = "✓" if ok else ("✗" if required else "–") + req_tag = "(required)" if required else "(optional)" + print(f" {icon} {label}: {detail} {req_tag}") + print() + if not all_required_ok: + print("Some required dependencies are missing.") + for label, ok, _, req in checks: + if not ok: + print(f"\n {label}:") + print(_get_install_hint(label)) + print() + + return all_required_ok