updated installer script

This commit is contained in:
2026-04-01 13:58:06 -07:00
parent 57b595c1a4
commit 849dcbda85
3 changed files with 324 additions and 359 deletions

View File

@@ -804,6 +804,7 @@ local _store_status_hint_for_url
local _refresh_current_store_url_status local _refresh_current_store_url_status
local _skip_next_store_check_url = '' local _skip_next_store_check_url = ''
local _pick_folder_windows local _pick_folder_windows
M._ytdl_download_format_fallbacks = M._ytdl_download_format_fallbacks or {}
function M._load_store_choices_direct_async(cb) function M._load_store_choices_direct_async(cb)
cb = cb or function() end cb = cb or function() end
@@ -2070,6 +2071,10 @@ function M._prepare_ytdl_format_for_web_load(url, reason)
return false return false
end 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, 'options/ytdl-format', '')
pcall(mp.set_property, 'file-local-options/ytdl-format', '') pcall(mp.set_property, 'file-local-options/ytdl-format', '')
pcall(mp.set_property, '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') .. ' reason=' .. tostring(reason or 'on-load')
.. ' url=' .. tostring(url) .. ' url=' .. tostring(url)
.. ' values=' .. table.concat(active_props, '; ') .. ' values=' .. table.concat(active_props, '; ')
.. ' fallback_cached=' .. tostring(first_value and first_value ~= '' and 'yes' or 'no')
) )
return true return true
end end
@@ -2119,6 +2125,28 @@ local function _normalize_url_for_store_lookup(url)
return url:lower() return url:lower()
end 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 function _build_store_lookup_needles(url)
local out = {} local out = {}
local seen = {} local seen = {}
@@ -4879,6 +4907,7 @@ local function _current_ytdl_format_string()
local fmt = trim(tostring(mp.get_property_native('ytdl-format') or '')) local fmt = trim(tostring(mp.get_property_native('ytdl-format') or ''))
local suspicious_reason = M._suspicious_ytdl_format_reason(fmt, url, raw) local suspicious_reason = M._suspicious_ytdl_format_reason(fmt, url, raw)
if fmt ~= '' and not suspicious_reason then if fmt ~= '' and not suspicious_reason then
M._remember_ytdl_download_format(url, fmt)
return fmt return fmt
elseif fmt ~= '' then elseif fmt ~= '' then
_lua_log('ytdl-format: ignoring suspicious current format source=ytdl-format value=' .. tostring(fmt) .. ' reason=' .. tostring(suspicious_reason)) _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 '')) local opt = trim(tostring(mp.get_property('options/ytdl-format') or ''))
suspicious_reason = M._suspicious_ytdl_format_reason(opt, url, raw) suspicious_reason = M._suspicious_ytdl_format_reason(opt, url, raw)
if opt ~= '' and not suspicious_reason then if opt ~= '' and not suspicious_reason then
M._remember_ytdl_download_format(url, opt)
return opt return opt
elseif opt ~= '' then elseif opt ~= '' then
_lua_log('ytdl-format: ignoring suspicious current format source=options/ytdl-format value=' .. tostring(opt) .. ' reason=' .. tostring(suspicious_reason)) _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) local raw_format_id = tostring(raw.format_id)
suspicious_reason = M._suspicious_ytdl_format_reason(raw_format_id, url, raw) suspicious_reason = M._suspicious_ytdl_format_reason(raw_format_id, url, raw)
if not suspicious_reason then if not suspicious_reason then
M._remember_ytdl_download_format(url, raw_format_id)
return raw_format_id return raw_format_id
end end
_lua_log('ytdl-format: ignoring suspicious current format source=ytdl-raw-info.format_id value=' .. tostring(raw_format_id) .. ' reason=' .. tostring(suspicious_reason)) _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, '+') local joined = table.concat(parts, '+')
suspicious_reason = M._suspicious_ytdl_format_reason(joined, url, raw) suspicious_reason = M._suspicious_ytdl_format_reason(joined, url, raw)
if not suspicious_reason then if not suspicious_reason then
M._remember_ytdl_download_format(url, joined)
return joined return joined
end end
_lua_log('ytdl-format: ignoring suspicious current format source=ytdl-raw-info.requested_formats value=' .. tostring(joined) .. ' reason=' .. tostring(suspicious_reason)) _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
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 return nil
end end
@@ -5200,6 +5243,7 @@ local function _apply_ytdl_format_and_reload(url, fmt)
_lua_log('change-format: setting ytdl format=' .. tostring(fmt)) _lua_log('change-format: setting ytdl format=' .. tostring(fmt))
_skip_next_store_check_url = _normalize_url_for_store_lookup(url) _skip_next_store_check_url = _normalize_url_for_store_lookup(url)
_set_current_web_url(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, 'options/ytdl-format', tostring(fmt))
pcall(mp.set_property, 'file-local-options/ytdl-format', tostring(fmt)) pcall(mp.set_property, 'file-local-options/ytdl-format', tostring(fmt))
pcall(mp.set_property, 'ytdl-format', tostring(fmt)) pcall(mp.set_property, 'ytdl-format', tostring(fmt))

View File

@@ -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 Note: This Python script is the canonical installer for the project — prefer
running `python ./scripts/bootstrap.py` locally. The platform scripts running `python ./scripts/bootstrap.py` locally. The platform scripts
(`scripts/bootstrap.ps1` and `scripts/bootstrap.sh`) are now thin wrappers (`scripts/bootstrap.ps1` and `scripts/bootstrap.sh`) are thin wrappers
that delegate to this script (they call it with `--no-delegate -q`). that delegate to this script.
When invoked without any arguments, `bootstrap.py` will automatically select and The install flow is owned here so `bootstrap.py` remains the single global
run the platform-specific bootstrap helper (`scripts/bootstrap.ps1` on Windows entry point, while platform wrappers only provide shell-specific convenience.
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.
This file replaces the old `scripts/setup.py` to ensure the repository only has This file replaces the old `scripts/setup.py` to ensure the repository only has
one `setup.py` (at the repository root) for packaging. one `setup.py` (at the repository root) for packaging.
Usage: 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 --skip-deps
python ./scripts/bootstrap.py --playwright-only python ./scripts/bootstrap.py --playwright-only
@@ -249,10 +244,16 @@ def run_platform_bootstrap(repo_root: Path) -> int:
return int(rc.returncode or 0) 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: try:
result = subprocess.run(
return True [interpreter, "-c", "import playwright"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return result.returncode == 0
except Exception: except Exception:
return False return False
@@ -446,7 +447,7 @@ def main() -> int:
parser.add_argument( parser.add_argument(
"--no-delegate", "--no-delegate",
action="store_true", 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( parser.add_argument(
"-q", "-q",
@@ -812,7 +813,7 @@ def main() -> int:
return False return False
def _interactive_menu() -> str | int: 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: try:
installed = _is_installed() installed = _is_installed()
while True: while True:
@@ -878,8 +879,7 @@ def main() -> int:
if choice in ("q", "quit", "exit"): if choice in ("q", "quit", "exit"):
return 0 return 0
except EOFError: except EOFError:
# Non-interactive, fall back to delegating to platform helper return "install"
return "delegate"
def _prompt_hydrus_install_location() -> tuple[Path, str] | None: def _prompt_hydrus_install_location() -> tuple[Path, str] | None:
"""Ask the user for the Hydrus installation root and folder name.""" """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: def _clone_repo(url: str, dest: Path, depth: int = 1) -> bool:
"""Helper to clone a repository.""" """Helper to clone a repository."""
try: try:
cmd = ["git", "clone", url, str(dest)] cmd = ["git", "clone"]
if depth: if depth:
cmd.extend(["--depth", str(depth)]) cmd.extend(["--depth", str(depth)])
cmd.extend([url, str(dest)])
subprocess.check_call(cmd) subprocess.check_call(cmd)
return True return True
except Exception as e: except Exception as e:
@@ -1318,25 +1319,14 @@ def main() -> int:
continue continue
elif sel == "uninstall": elif sel == "uninstall":
return _do_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: elif sel == 0:
return 0 return 0
elif sel == "menu": elif sel == "menu":
continue continue
elif not args.no_delegate and script_path is not None:
# Default non-interactive behavior: delegate to platform script if not is_in_repo:
rc = run_platform_bootstrap(repo_root) if not _ensure_repo_available():
if rc != 0: return 1
return rc
if not args.quiet:
print("Platform bootstrap completed successfully.")
return 0
if sys.version_info < (3, 8): if sys.version_info < (3, 8):
print("Warning: Python 3.8+ is recommended.", file=sys.stderr) 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) 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 return 1
# Determine total steps for progress bar should_install_deps = not args.skip_deps and not args.playwright_only
total_steps = 7 # Base: venv, pip, deps, project, cli, finalize, env should_install_playwright = not args.no_playwright
if args.upgrade_pip: total_steps += 1 should_install_project = not args.playwright_only
if not args.no_playwright: total_steps += 1 # Playwright is combined pkg+browsers
if not getattr(args, "no_mpv", False): total_steps += 1 total_steps = 2
if not getattr(args, "no_deno", False): total_steps += 1 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) pb = ProgressBar(total_steps, quiet=args.quiet or args.debug)
@@ -1437,17 +1442,12 @@ def main() -> int:
pb.update("Checking for pip...") pb.update("Checking for pip...")
_ensure_pip_available(venv_python) _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: try:
if args.playwright_only: if args.playwright_only:
# Playwright browser install (short-circuit) # Playwright browser install (short-circuit)
if not playwright_package_installed(): pb.update("Setting up Playwright and browsers...")
_run_cmd([sys.executable, "-m", "pip", "install", "--no-cache-dir", "playwright"]) if not playwright_package_installed(venv_python):
_run_cmd([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "playwright"])
try: try:
cmd = _build_playwright_install_cmd(args.browsers) cmd = _build_playwright_install_cmd(args.browsers)
@@ -1476,15 +1476,16 @@ def main() -> int:
) )
# 4. Core Dependencies # 4. Core Dependencies
pb.update("Installing core dependencies...")
req_file = repo_root / "scripts" / "requirements.txt" req_file = repo_root / "scripts" / "requirements.txt"
if should_install_deps:
pb.update("Installing core dependencies...")
if req_file.exists(): if req_file.exists():
_run_cmd([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "-r", str(req_file)]) _run_cmd([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "-r", str(req_file)])
# 5. Playwright Setup # 5. Playwright Setup
if not args.no_playwright: if should_install_playwright:
pb.update("Setting up Playwright and browsers...") 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"]) _run_cmd([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "playwright"])
try: try:
@@ -1495,6 +1496,7 @@ def main() -> int:
pass pass
# 6. Internal Components # 6. Internal Components
if should_install_project:
pb.update("Installing internal components...") pb.update("Installing internal components...")
if platform.system() != "Windows": if platform.system() != "Windows":
old_mm = venv_dir / "bin" / "mm" old_mm = venv_dir / "bin" / "mm"
@@ -1504,7 +1506,12 @@ def main() -> int:
except Exception: except Exception:
pass pass
_run_cmd([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "-e", str(repo_root / "scripts")]) 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 # 7. CLI Verification
pb.update("Verifying CLI configuration...") pb.update("Verifying CLI configuration...")

View File

@@ -21,6 +21,7 @@ Examples:
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import os import os
import shutil import shutil
import subprocess import subprocess
@@ -156,7 +157,7 @@ def run_git_clone(
def run_git_pull(git: str, dest: Path) -> None: def run_git_pull(git: str, dest: Path) -> None:
logging.info("Updating git repository in %s", dest) 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: def _sanitize_store_name(name: str) -> str:
@@ -672,6 +673,186 @@ def parse_requirements_file(req_path: Path) -> list[str]:
return names 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: def open_in_editor(path: Path) -> bool:
"""Open the file using the OS default opener. """Open the file using the OS default opener.
@@ -1034,34 +1215,14 @@ def main(argv: Optional[list[str]] = None) -> int:
if choice == "1": if choice == "1":
# Install dependencies into the repository venv (create venv if needed) # Install dependencies into the repository venv (create venv if needed)
try: try:
venv_dir = dest / str( venv_py = ensure_repo_venv(
getattr(args, dest,
"venv_name", getattr(args, "venv_name", ".venv"),
".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: except Exception as e:
logging.error("Failed to prepare venv: %s", e) logging.error("Failed to prepare venv: %s", e)
return 8 return 8
if not venv_py:
logging.error(
"Could not locate python in venv %s",
venv_dir
)
return 9
req = find_requirements(dest) req = find_requirements(dest)
if not req: if not req:
logging.info( logging.info(
@@ -1070,96 +1231,18 @@ def main(argv: Optional[list[str]] = None) -> int:
) )
return 0 return 0
logging.info(
"Installing dependencies from %s into venv",
req
)
try: try:
subprocess.run( install_requirements_into_venv(
[ venv_py,
str(venv_py), dest,
"-m", req,
"pip", reinstall=getattr(args, "reinstall_deps", False),
"install",
"--upgrade",
"pip"
],
check=True,
) )
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: except subprocess.CalledProcessError as e:
logging.error("Failed to install dependencies: %s", e) logging.error("Failed to install dependencies: %s", e)
return 10 return 10
# Post-install verification verify_requirements_in_venv(venv_py, req)
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"
)
return 0 return 0
@@ -1180,24 +1263,11 @@ def main(argv: Optional[list[str]] = None) -> int:
elif choice == "3": elif choice == "3":
# Install a user-level service to start the hydrus client on boot # Install a user-level service to start the hydrus client on boot
try: try:
venv_dir = dest / str( venv_py = ensure_repo_venv(
getattr(args, dest,
"venv_name", getattr(args, "venv_name", ".venv"),
".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: except Exception as e:
logging.error( logging.error(
"Failed to prepare venv for service install: %s", "Failed to prepare venv for service install: %s",
@@ -1205,13 +1275,6 @@ def main(argv: Optional[list[str]] = None) -> int:
) )
return 8 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 # Prefer the helper script inside the repo if present, else use installed helper
script_dir = Path(__file__).resolve().parent script_dir = Path(__file__).resolve().parent
helper_candidates = [ helper_candidates = [
@@ -1336,36 +1399,11 @@ def main(argv: Optional[list[str]] = None) -> int:
# Post-obtain setup: create repository-local venv (unless disabled) # Post-obtain setup: create repository-local venv (unless disabled)
if not getattr(args, "no_venv", False): if not getattr(args, "no_venv", False):
try: try:
venv_py = None venv_py = ensure_repo_venv(
venv_dir = dest / str(getattr(args, "venv_name", ".venv")) dest,
if venv_dir.exists(): getattr(args, "venv_name", ".venv"),
if getattr(args, "recreate_venv", False): recreate=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)
# Optionally install or reinstall requirements.txt # Optionally install or reinstall requirements.txt
if getattr(args, if getattr(args,
@@ -1375,150 +1413,26 @@ def main(argv: Optional[list[str]] = None) -> int:
False): False):
req = find_requirements(dest) req = find_requirements(dest)
if req and req.exists(): if req and req.exists():
logging.info(
"Installing dependencies from %s into venv (reinstall=%s)",
req,
bool(getattr(args,
"reinstall_deps",
False)),
)
try: try:
subprocess.run( install_requirements_into_venv(
[ venv_py,
str(venv_py), dest,
"-m", req,
"pip", reinstall=getattr(args, "reinstall_deps", False),
"install",
"--upgrade",
"pip"
],
check=True,
) )
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: except subprocess.CalledProcessError as e:
logging.error("Failed to install dependencies: %s", e) logging.error("Failed to install dependencies: %s", e)
return 10 return 10
# Post-install verification: ensure packages are visible inside the venv if not verify_requirements_in_venv(venv_py, req):
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( logging.warning(
"Package '%s' not found in venv (pip show failed).", "To re-install and verify, run:\n %s -m pip install -r %s\nThen run the client with:\n %s %s",
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, venv_py,
req, req,
venv_py, venv_py,
dest / "hydrus_client.py", dest / "hydrus_client.py",
) )
else:
logging.debug(
"No parseable packages found in %s for verification; skipping further checks",
req,
)
else: else:
logging.info( logging.info(
"No requirements.txt found in common locations; skipping dependency installation" "No requirements.txt found in common locations; skipping dependency installation"