diff --git a/MPV/LUA/main.lua b/MPV/LUA/main.lua index 55bc09d..340f580 100644 --- a/MPV/LUA/main.lua +++ b/MPV/LUA/main.lua @@ -804,6 +804,7 @@ local _store_status_hint_for_url local _refresh_current_store_url_status local _skip_next_store_check_url = '' local _pick_folder_windows +M._ytdl_download_format_fallbacks = M._ytdl_download_format_fallbacks or {} function M._load_store_choices_direct_async(cb) cb = cb or function() end @@ -2070,6 +2071,10 @@ function M._prepare_ytdl_format_for_web_load(url, reason) return false end + if explicit_reload_url ~= '' and first_value and first_value ~= '' then + M._ytdl_download_format_fallbacks[explicit_reload_url] = first_value + end + pcall(mp.set_property, 'options/ytdl-format', '') pcall(mp.set_property, 'file-local-options/ytdl-format', '') pcall(mp.set_property, 'ytdl-format', '') @@ -2078,6 +2083,7 @@ function M._prepare_ytdl_format_for_web_load(url, reason) .. ' reason=' .. tostring(reason or 'on-load') .. ' url=' .. tostring(url) .. ' values=' .. table.concat(active_props, '; ') + .. ' fallback_cached=' .. tostring(first_value and first_value ~= '' and 'yes' or 'no') ) return true end @@ -2119,6 +2125,28 @@ local function _normalize_url_for_store_lookup(url) return url:lower() end +function M._remember_ytdl_download_format(url, fmt) + local normalized = _normalize_url_for_store_lookup(url) + local value = trim(tostring(fmt or '')) + if normalized == '' or value == '' then + return false + end + M._ytdl_download_format_fallbacks[normalized] = value + return true +end + +function M._get_remembered_ytdl_download_format(url) + local normalized = _normalize_url_for_store_lookup(url) + if normalized == '' then + return nil + end + local cached = trim(tostring((M._ytdl_download_format_fallbacks or {})[normalized] or '')) + if cached == '' then + return nil + end + return cached +end + local function _build_store_lookup_needles(url) local out = {} local seen = {} @@ -4879,6 +4907,7 @@ local function _current_ytdl_format_string() local fmt = trim(tostring(mp.get_property_native('ytdl-format') or '')) local suspicious_reason = M._suspicious_ytdl_format_reason(fmt, url, raw) if fmt ~= '' and not suspicious_reason then + M._remember_ytdl_download_format(url, fmt) return fmt elseif fmt ~= '' then _lua_log('ytdl-format: ignoring suspicious current format source=ytdl-format value=' .. tostring(fmt) .. ' reason=' .. tostring(suspicious_reason)) @@ -4888,6 +4917,7 @@ local function _current_ytdl_format_string() local opt = trim(tostring(mp.get_property('options/ytdl-format') or '')) suspicious_reason = M._suspicious_ytdl_format_reason(opt, url, raw) if opt ~= '' and not suspicious_reason then + M._remember_ytdl_download_format(url, opt) return opt elseif opt ~= '' then _lua_log('ytdl-format: ignoring suspicious current format source=options/ytdl-format value=' .. tostring(opt) .. ' reason=' .. tostring(suspicious_reason)) @@ -4898,6 +4928,7 @@ local function _current_ytdl_format_string() local raw_format_id = tostring(raw.format_id) suspicious_reason = M._suspicious_ytdl_format_reason(raw_format_id, url, raw) if not suspicious_reason then + M._remember_ytdl_download_format(url, raw_format_id) return raw_format_id end _lua_log('ytdl-format: ignoring suspicious current format source=ytdl-raw-info.format_id value=' .. tostring(raw_format_id) .. ' reason=' .. tostring(suspicious_reason)) @@ -4914,6 +4945,7 @@ local function _current_ytdl_format_string() local joined = table.concat(parts, '+') suspicious_reason = M._suspicious_ytdl_format_reason(joined, url, raw) if not suspicious_reason then + M._remember_ytdl_download_format(url, joined) return joined end _lua_log('ytdl-format: ignoring suspicious current format source=ytdl-raw-info.requested_formats value=' .. tostring(joined) .. ' reason=' .. tostring(suspicious_reason)) @@ -4921,6 +4953,17 @@ local function _current_ytdl_format_string() end end + if url ~= '' and _is_ytdlp_url(url) then + local remembered = M._get_remembered_ytdl_download_format(url) + suspicious_reason = M._suspicious_ytdl_format_reason(remembered, url, raw) + if remembered and not suspicious_reason then + _lua_log('ytdl-format: using remembered download fallback value=' .. tostring(remembered) .. ' url=' .. tostring(url)) + return remembered + elseif remembered then + _lua_log('ytdl-format: ignoring suspicious remembered fallback value=' .. tostring(remembered) .. ' reason=' .. tostring(suspicious_reason)) + end + end + return nil end @@ -5200,6 +5243,7 @@ local function _apply_ytdl_format_and_reload(url, fmt) _lua_log('change-format: setting ytdl format=' .. tostring(fmt)) _skip_next_store_check_url = _normalize_url_for_store_lookup(url) _set_current_web_url(url) + M._remember_ytdl_download_format(url, fmt) pcall(mp.set_property, 'options/ytdl-format', tostring(fmt)) pcall(mp.set_property, 'file-local-options/ytdl-format', tostring(fmt)) pcall(mp.set_property, 'ytdl-format', tostring(fmt)) diff --git a/scripts/bootstrap.py b/scripts/bootstrap.py index 0775f32..a7dabcd 100644 --- a/scripts/bootstrap.py +++ b/scripts/bootstrap.py @@ -14,22 +14,17 @@ ffmpeg-python is installed as a dependency, but requires ffmpeg itself to be on Note: This Python script is the canonical installer for the project — prefer running `python ./scripts/bootstrap.py` locally. The platform scripts -(`scripts/bootstrap.ps1` and `scripts/bootstrap.sh`) are now thin wrappers -that delegate to this script (they call it with `--no-delegate -q`). +(`scripts/bootstrap.ps1` and `scripts/bootstrap.sh`) are thin wrappers +that delegate to this script. -When invoked without any arguments, `bootstrap.py` will automatically select and -run the platform-specific bootstrap helper (`scripts/bootstrap.ps1` on Windows -or `scripts/bootstrap.sh` on POSIX) in **non-interactive (quiet)** mode so a -single `python ./scripts/bootstrap.py` call does the usual bootstrap on your OS. - -The platform bootstrap scripts also attempt (best-effort) to install `mpv` if -it is not found on your PATH, since some workflows use it. +The install flow is owned here so `bootstrap.py` remains the single global +entry point, while platform wrappers only provide shell-specific convenience. This file replaces the old `scripts/setup.py` to ensure the repository only has one `setup.py` (at the repository root) for packaging. Usage: - python ./scripts/bootstrap.py # install deps and playwright browsers (or run platform bootstrap if no args) + python ./scripts/bootstrap.py # install deps and playwright browsers python ./scripts/bootstrap.py --skip-deps python ./scripts/bootstrap.py --playwright-only @@ -249,10 +244,16 @@ def run_platform_bootstrap(repo_root: Path) -> int: return int(rc.returncode or 0) -def playwright_package_installed() -> bool: +def playwright_package_installed(python_path: Optional[Path] = None) -> bool: + interpreter = str(python_path or sys.executable) try: - - return True + result = subprocess.run( + [interpreter, "-c", "import playwright"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 except Exception: return False @@ -446,7 +447,7 @@ def main() -> int: parser.add_argument( "--no-delegate", action="store_true", - help="Do not delegate to platform bootstrap scripts; run the Python bootstrap directly.", + help="Legacy no-op retained for wrapper compatibility; Python bootstrap is always the canonical entry point.", ) parser.add_argument( "-q", @@ -812,7 +813,7 @@ def main() -> int: return False def _interactive_menu() -> str | int: - """Show a simple interactive menu to choose install/uninstall or delegate.""" + """Show a simple interactive menu to choose install/uninstall tasks.""" try: installed = _is_installed() while True: @@ -878,8 +879,7 @@ def main() -> int: if choice in ("q", "quit", "exit"): return 0 except EOFError: - # Non-interactive, fall back to delegating to platform helper - return "delegate" + return "install" def _prompt_hydrus_install_location() -> tuple[Path, str] | None: """Ask the user for the Hydrus installation root and folder name.""" @@ -907,9 +907,10 @@ def main() -> int: def _clone_repo(url: str, dest: Path, depth: int = 1) -> bool: """Helper to clone a repository.""" try: - cmd = ["git", "clone", url, str(dest)] + cmd = ["git", "clone"] if depth: cmd.extend(["--depth", str(depth)]) + cmd.extend([url, str(dest)]) subprocess.check_call(cmd) return True except Exception as e: @@ -1318,25 +1319,14 @@ def main() -> int: continue elif sel == "uninstall": return _do_uninstall() - elif sel == "delegate": - rc = run_platform_bootstrap(repo_root) - if rc != 0: - return rc - if not args.quiet: - print("Platform bootstrap completed successfully.") - return 0 elif sel == 0: return 0 elif sel == "menu": continue - elif not args.no_delegate and script_path is not None: - # Default non-interactive behavior: delegate to platform script - rc = run_platform_bootstrap(repo_root) - if rc != 0: - return rc - if not args.quiet: - print("Platform bootstrap completed successfully.") - return 0 + + if not is_in_repo: + if not _ensure_repo_available(): + return 1 if sys.version_info < (3, 8): print("Warning: Python 3.8+ is recommended.", file=sys.stderr) @@ -1360,12 +1350,27 @@ def main() -> int: print("Error: No project repository found. Please ensure you are running this script inside the project folder or follow the interactive install prompts.", file=sys.stderr) return 1 - # Determine total steps for progress bar - total_steps = 7 # Base: venv, pip, deps, project, cli, finalize, env - if args.upgrade_pip: total_steps += 1 - if not args.no_playwright: total_steps += 1 # Playwright is combined pkg+browsers - if not getattr(args, "no_mpv", False): total_steps += 1 - if not getattr(args, "no_deno", False): total_steps += 1 + should_install_deps = not args.skip_deps and not args.playwright_only + should_install_playwright = not args.no_playwright + should_install_project = not args.playwright_only + + total_steps = 2 + if args.playwright_only: + total_steps += 1 + else: + if args.upgrade_pip: + total_steps += 1 + if should_install_deps: + total_steps += 1 + if should_install_playwright: + total_steps += 1 + if should_install_project: + total_steps += 1 + total_steps += 3 + if not getattr(args, "no_mpv", False): + total_steps += 1 + if not getattr(args, "no_deno", False): + total_steps += 1 pb = ProgressBar(total_steps, quiet=args.quiet or args.debug) @@ -1437,17 +1442,12 @@ def main() -> int: pb.update("Checking for pip...") _ensure_pip_available(venv_python) - # Enforce opinionated behavior: install deps, playwright, deno, and install project in editable mode. - # Ignore `--skip-deps` and `--install-editable` flags to keep the setup deterministic. - args.skip_deps = False - args.install_editable = True - args.no_playwright = False - try: if args.playwright_only: # Playwright browser install (short-circuit) - if not playwright_package_installed(): - _run_cmd([sys.executable, "-m", "pip", "install", "--no-cache-dir", "playwright"]) + pb.update("Setting up Playwright and browsers...") + if not playwright_package_installed(venv_python): + _run_cmd([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "playwright"]) try: cmd = _build_playwright_install_cmd(args.browsers) @@ -1476,15 +1476,16 @@ def main() -> int: ) # 4. Core Dependencies - pb.update("Installing core dependencies...") req_file = repo_root / "scripts" / "requirements.txt" - if req_file.exists(): - _run_cmd([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "-r", str(req_file)]) + if should_install_deps: + pb.update("Installing core dependencies...") + if req_file.exists(): + _run_cmd([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "-r", str(req_file)]) # 5. Playwright Setup - if not args.no_playwright: + if should_install_playwright: pb.update("Setting up Playwright and browsers...") - if not playwright_package_installed(): + if not playwright_package_installed(venv_python): _run_cmd([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "playwright"]) try: @@ -1495,16 +1496,22 @@ def main() -> int: pass # 6. Internal Components - pb.update("Installing internal components...") - if platform.system() != "Windows": - old_mm = venv_dir / "bin" / "mm" - if old_mm.exists(): - try: - old_mm.unlink() - except Exception: - pass - - _run_cmd([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "-e", str(repo_root / "scripts")]) + if should_install_project: + pb.update("Installing internal components...") + if platform.system() != "Windows": + old_mm = venv_dir / "bin" / "mm" + if old_mm.exists(): + try: + old_mm.unlink() + except Exception: + pass + + project_cmd = [str(venv_python), "-m", "pip", "install", "--no-cache-dir"] + if args.install_editable: + project_cmd.extend(["-e", str(repo_root / "scripts")]) + else: + project_cmd.append(str(repo_root / "scripts")) + _run_cmd(project_cmd) # 7. CLI Verification pb.update("Verifying CLI configuration...") diff --git a/scripts/hydrusnetwork.py b/scripts/hydrusnetwork.py index 4e6d15d..e9ce052 100644 --- a/scripts/hydrusnetwork.py +++ b/scripts/hydrusnetwork.py @@ -21,6 +21,7 @@ Examples: from __future__ import annotations import argparse +import json import os import shutil import subprocess @@ -156,7 +157,7 @@ def run_git_clone( def run_git_pull(git: str, dest: Path) -> None: logging.info("Updating git repository in %s", dest) - subprocess.run([git, "-C", str(dest), "pull"], check=True) + subprocess.run([git, "-C", str(dest), "pull", "--ff-only"], check=True) def _sanitize_store_name(name: str) -> str: @@ -672,6 +673,186 @@ def parse_requirements_file(req_path: Path) -> list[str]: return names +IMPORT_NAME_OVERRIDES = { + "pyyaml": "yaml", + "pillow": "PIL", + "python-dateutil": "dateutil", + "beautifulsoup4": "bs4", + "pillow-heif": "pillow_heif", + "pillow-jxl-plugin": "pillow_jxl", + "pyopenssl": "OpenSSL", + "pysocks": "socks", + "service-identity": "service_identity", + "show-in-file-manager": "showinfm", + "opencv-python-headless": "cv2", + "mpv": "mpv", + "pyside6": "PySide6", + "pyside6-essentials": "PySide6", + "pyside6-addons": "PySide6", +} + + +def ensure_repo_venv( + repo_root: Path, + venv_name: str = ".venv", + recreate: bool = False, + purpose: Optional[str] = None, +) -> Path: + venv_dir = repo_root / str(venv_name) + if venv_dir.exists(): + if recreate: + logging.info("Removing existing venv: %s", venv_dir) + shutil.rmtree(venv_dir) + else: + logging.info("Using existing venv at %s", venv_dir) + + if not venv_dir.exists(): + if purpose: + logging.info("Creating venv at %s for %s", venv_dir, purpose) + else: + logging.info("Creating venv at %s", venv_dir) + subprocess.run([sys.executable, "-m", "venv", str(venv_dir)], check=True) + + venv_py = get_python_in_venv(venv_dir) + if not venv_py: + raise RuntimeError(f"Could not locate python in venv {venv_dir}") + + logging.info("Venv ready: %s", venv_py) + return venv_py + + +def install_requirements_into_venv( + venv_py: Path, + repo_root: Path, + req_path: Path, + reinstall: bool = False, +) -> None: + logging.info( + "Installing dependencies from %s into venv (reinstall=%s)", + req_path, + bool(reinstall), + ) + subprocess.run( + [ + str(venv_py), + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--upgrade", + "pip", + ], + check=True, + ) + + cmd = [ + str(venv_py), + "-m", + "pip", + "install", + "--disable-pip-version-check", + ] + if reinstall: + cmd.extend(["--upgrade", "--force-reinstall"]) + cmd.extend(["-r", str(req_path)]) + subprocess.run(cmd, cwd=str(repo_root), check=True) + logging.info("Dependencies installed successfully") + + +def verify_requirements_in_venv(venv_py: Path, req_path: Path) -> bool: + packages = parse_requirements_file(req_path) + if not packages: + logging.debug( + "No parseable packages found in %s for verification; skipping further checks", + req_path, + ) + return True + + logging.info("Running pip consistency check inside the venv...") + pip_check = subprocess.run( + [str(venv_py), "-m", "pip", "check"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + if pip_check.returncode != 0: + output = (pip_check.stdout or "").strip() or "Unknown dependency issue" + logging.warning("pip check reported issues:\n%s", output) + + seen_modules: set[str] = set() + targets: list[tuple[str, str]] = [] + for package in packages: + module_name = IMPORT_NAME_OVERRIDES.get(package, package) + if module_name in seen_modules: + continue + seen_modules.add(module_name) + targets.append((package, module_name)) + + verify_script = ( + "import importlib, json\n" + f"targets = {targets!r}\n" + "failures = []\n" + "for package, module_name in targets:\n" + " try:\n" + " importlib.import_module(module_name)\n" + " except Exception as exc:\n" + " failures.append((package, module_name, f'{type(exc).__name__}: {exc}'))\n" + "print(json.dumps(failures))\n" + "raise SystemExit(1 if failures else 0)\n" + ) + result = subprocess.run( + [str(venv_py), "-c", verify_script], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + failures: list[tuple[str, str, str]] = [] + raw_output = (result.stdout or "").strip() + if raw_output: + try: + decoded = json.loads(raw_output) + failures = [tuple(item) for item in decoded] + except json.JSONDecodeError: + logging.warning( + "Dependency import verification returned unexpected output: %s", + raw_output, + ) + + any_missing = False + for package, module_name, error in failures: + if module_name == "mpv": + logging.info( + "Package '%s' is installed, but import '%s' failed (likely missing system libmpv). This is usually non-critical.", + package, + module_name, + ) + continue + + logging.warning( + "Package '%s' appears installed but import '%s' failed inside venv: %s", + package, + module_name, + error, + ) + any_missing = True + + if result.returncode != 0 and not failures: + stderr_text = (result.stderr or "").strip() + if stderr_text: + logging.warning("Dependency import verification failed: %s", stderr_text) + any_missing = True + + if pip_check.returncode == 0 and not any_missing: + logging.info("Dependency verification completed successfully") + return True + + logging.warning( + "Some dependencies may not be importable in the venv; consider running with --reinstall-deps" + ) + return False + + def open_in_editor(path: Path) -> bool: """Open the file using the OS default opener. @@ -1034,34 +1215,14 @@ def main(argv: Optional[list[str]] = None) -> int: if choice == "1": # Install dependencies into the repository venv (create venv if needed) try: - venv_dir = dest / str( - getattr(args, - "venv_name", - ".venv") + venv_py = ensure_repo_venv( + dest, + getattr(args, "venv_name", ".venv"), ) - if venv_dir.exists(): - logging.info("Using existing venv at %s", venv_dir) - else: - logging.info("Creating venv at %s", venv_dir) - subprocess.run( - [sys.executable, - "-m", - "venv", - str(venv_dir)], - check=True - ) - venv_py = get_python_in_venv(venv_dir) except Exception as e: logging.error("Failed to prepare venv: %s", e) return 8 - if not venv_py: - logging.error( - "Could not locate python in venv %s", - venv_dir - ) - return 9 - req = find_requirements(dest) if not req: logging.info( @@ -1070,96 +1231,18 @@ def main(argv: Optional[list[str]] = None) -> int: ) return 0 - logging.info( - "Installing dependencies from %s into venv", - req - ) try: - subprocess.run( - [ - str(venv_py), - "-m", - "pip", - "install", - "--upgrade", - "pip" - ], - check=True, + install_requirements_into_venv( + venv_py, + dest, + req, + reinstall=getattr(args, "reinstall_deps", False), ) - subprocess.run( - [ - str(venv_py), - "-m", - "pip", - "install", - "--upgrade", - "-r", - str(req) - ], - cwd=str(dest), - check=True, - ) - logging.info("Dependencies installed successfully") except subprocess.CalledProcessError as e: logging.error("Failed to install dependencies: %s", e) return 10 - # Post-install verification - pkgs = parse_requirements_file(req) - if pkgs: - logging.info( - "Verifying installed packages inside the venv..." - ) - any_missing = False - import_map = { - "pyyaml": "yaml", - "pillow": "PIL", - "python-dateutil": "dateutil", - "beautifulsoup4": "bs4", - "pillow-heif": "pillow_heif", - "pillow-jxl-plugin": "pillow_jxl", - "pyopenssl": "OpenSSL", - "pysocks": "socks", - "service-identity": "service_identity", - "show-in-file-manager": "showinfm", - "opencv-python-headless": "cv2", - "mpv": "mpv", - "pyside6": "PySide6", - "pyside6-essentials": "PySide6", - "pyside6-addons": "PySide6", - } - for pkg in pkgs: - mod = import_map.get(pkg, pkg) - try: - subprocess.run( - [str(venv_py), - "-c", - f"import {mod}"], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception: - if mod == "mpv": - # python-mpv requires system libmpv; failure is common on server/headless envs - logging.info("Package '%s' is installed, but 'import %s' failed (likely missing system libmpv). This is usually non-critical.", pkg, mod) - continue - - logging.warning( - "Package '%s' not importable inside venv (module %s)", - pkg, - mod, - ) - any_missing = True - - if any_missing: - logging.warning( - "Some packages failed to import inside the venv; consider running with --reinstall-deps" - ) - else: - logging.info( - "All packages imported successfully inside the venv" - ) + verify_requirements_in_venv(venv_py, req) return 0 @@ -1180,24 +1263,11 @@ def main(argv: Optional[list[str]] = None) -> int: elif choice == "3": # Install a user-level service to start the hydrus client on boot try: - venv_dir = dest / str( - getattr(args, - "venv_name", - ".venv") + venv_py = ensure_repo_venv( + dest, + getattr(args, "venv_name", ".venv"), + purpose="service install", ) - if not venv_dir.exists(): - logging.info( - "Creating venv at %s to perform service install", - venv_dir - ) - subprocess.run( - [sys.executable, - "-m", - "venv", - str(venv_dir)], - check=True - ) - venv_py = get_python_in_venv(venv_dir) except Exception as e: logging.error( "Failed to prepare venv for service install: %s", @@ -1205,13 +1275,6 @@ def main(argv: Optional[list[str]] = None) -> int: ) return 8 - if not venv_py: - logging.error( - "Could not locate python in venv %s; cannot manage service.", - venv_dir, - ) - return 9 - # Prefer the helper script inside the repo if present, else use installed helper script_dir = Path(__file__).resolve().parent helper_candidates = [ @@ -1336,36 +1399,11 @@ def main(argv: Optional[list[str]] = None) -> int: # Post-obtain setup: create repository-local venv (unless disabled) if not getattr(args, "no_venv", False): try: - venv_py = None - venv_dir = dest / str(getattr(args, "venv_name", ".venv")) - if venv_dir.exists(): - if getattr(args, "recreate_venv", False): - logging.info("Removing existing venv: %s", venv_dir) - shutil.rmtree(venv_dir) - else: - logging.info("Using existing venv at %s", venv_dir) - if not venv_dir.exists(): - logging.info("Creating venv at %s", venv_dir) - try: - subprocess.run( - [sys.executable, - "-m", - "venv", - str(venv_dir)], - check=True - ) - except subprocess.CalledProcessError as e: - logging.error("Failed to create venv: %s", e) - return 8 - try: - venv_py = get_python_in_venv(venv_dir) - except Exception: - venv_py = None - if not venv_py: - logging.error("Could not locate python in venv %s", venv_dir) - return 9 - - logging.info("Venv ready: %s", venv_py) + venv_py = ensure_repo_venv( + dest, + getattr(args, "venv_name", ".venv"), + recreate=getattr(args, "recreate_venv", False), + ) # Optionally install or reinstall requirements.txt if getattr(args, @@ -1375,148 +1413,24 @@ def main(argv: Optional[list[str]] = None) -> int: False): req = find_requirements(dest) if req and req.exists(): - logging.info( - "Installing dependencies from %s into venv (reinstall=%s)", - req, - bool(getattr(args, - "reinstall_deps", - False)), - ) try: - subprocess.run( - [ - str(venv_py), - "-m", - "pip", - "install", - "--upgrade", - "pip" - ], - check=True, + install_requirements_into_venv( + venv_py, + dest, + req, + reinstall=getattr(args, "reinstall_deps", False), ) - if getattr(args, "reinstall_deps", False): - subprocess.run( - [ - str(venv_py), - "-m", - "pip", - "install", - "--upgrade", - "--force-reinstall", - "-r", - str(req), - ], - cwd=str(dest), - check=True, - ) - else: - subprocess.run( - [ - str(venv_py), - "-m", - "pip", - "install", - "-r", - str(req) - ], - cwd=str(dest), - check=True, - ) - logging.info("Dependencies installed successfully") except subprocess.CalledProcessError as e: logging.error("Failed to install dependencies: %s", e) return 10 - # Post-install verification: ensure packages are visible inside the venv - pkgs = parse_requirements_file(req) - if pkgs: - logging.info( - "Verifying installed packages inside the venv..." - ) - any_missing = False - # Small mapping for known differences between package name and import name - import_map = { - "pyyaml": "yaml", - "pillow": "PIL", - "python-dateutil": "dateutil", - "beautifulsoup4": "bs4", - "pillow-heif": "pillow_heif", - "pillow-jxl-plugin": "pillow_jxl", - "pyopenssl": "OpenSSL", - "pysocks": "socks", - "service-identity": "service_identity", - "show-in-file-manager": "showinfm", - "opencv-python-headless": "cv2", - "mpv": "mpv", - "pyside6": "PySide6", - "pyside6-essentials": "PySide6", - "pyside6-addons": "PySide6", - } - for pkg in pkgs: - try: - out = subprocess.run( - [str(venv_py), - "-m", - "pip", - "show", - pkg], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - if out.returncode != 0 or not out.stdout.strip(): - logging.warning( - "Package '%s' not found in venv (pip show failed).", - pkg - ) - any_missing = True - continue - # Try import test for common mappings - import_name = import_map.get(pkg, pkg) - try: - subprocess.run( - [ - str(venv_py), - "-c", - f"import {import_name}" - ], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except subprocess.CalledProcessError: - if import_name == "mpv": - # python-mpv requires system libmpv; failure is common on server/headless envs - logging.info("Package '%s' is installed, but 'import %s' failed (likely missing system libmpv). This is usually non-critical.", pkg, import_name) - continue - - logging.warning( - "Package '%s' appears installed but 'import %s' failed inside venv.", - pkg, - import_name, - ) - any_missing = True - except Exception as exc: - logging.debug( - "Verification error for package %s: %s", - pkg, - exc - ) - any_missing = True - - if any_missing: - logging.warning( - "Some packages may not be importable in the venv. To re-install and verify, run:\n %s -m pip install -r %s\nThen run the client with:\n %s %s", - venv_py, - req, - venv_py, - dest / "hydrus_client.py", - ) - - else: - logging.debug( - "No parseable packages found in %s for verification; skipping further checks", + if not verify_requirements_in_venv(venv_py, req): + logging.warning( + "To re-install and verify, run:\n %s -m pip install -r %s\nThen run the client with:\n %s %s", + venv_py, req, + venv_py, + dest / "hydrus_client.py", ) else: