"""Measured dependency status and isolated installers for the helper.""" from __future__ import annotations import importlib.metadata import importlib.util import json import re import subprocess import sys import tempfile import urllib.request import zipfile from pathlib import Path from host.media import ROOT, NO_WINDOW, config, run_small, tool from host.bundle import manifest LABELS = {"python": "Python", "yt_dlp": "yt-dlp", "ejs": "YouTube JS (EJS)", "node": "Node.js", "ffmpeg": "FFmpeg + FFprobe"} def status(): python_ok = sys.version_info >= (3, 10) result = [{"id": "python", "label": LABELS["python"], "status": "installed" if python_ok else "missing", "version": sys.version.split()[0]}] for key, package in (("yt_dlp", "yt-dlp"), ("ejs", "yt-dlp-ejs")): try: version = importlib.metadata.version(package) result.append({"id": key, "label": LABELS[key], "status": "installed", "version": version}) except importlib.metadata.PackageNotFoundError: result.append({"id": key, "label": LABELS[key], "status": "missing", "version": ""}) for key in ("node", "ffmpeg"): try: measured = run_small([tool(key), "--version" if key == "node" else "-version"], timeout=8) first = measured.stdout.splitlines()[0] if measured.stdout else "" if measured.returncode != 0 or not first: raise ValueError(f"Version check failed ({measured.returncode}): {measured.stderr[-200:]}") version = first if key == "node" else first.split("version ", 1)[-1].split(" ", 1)[0] installed = int(version.lstrip("v").split(".", 1)[0]) >= 22 if key == "node" else run_small([tool("ffprobe"), "-version"], timeout=8).returncode == 0 result.append({"id": key, "label": LABELS[key], "status": "installed" if installed else "outdated", "version": version}) except (OSError, ValueError, subprocess.TimeoutExpired) as error: result.append({"id": key, "label": LABELS[key], "status": "missing", "version": "", "message": str(error)[:300]}) return result def fetch(url, destination, progress): request = urllib.request.Request(url, headers={"User-Agent": "YouTubeDL-Helper/1.1"}) with urllib.request.urlopen(request, timeout=30) as response, destination.open("wb") as output: total = int(response.headers.get("Content-Length", 0)); received = 0 while chunk := response.read(1024 * 512): output.write(chunk); received += len(chunk) progress(min(75, received / total * 75) if total else None, "Скачиваю компонент") def extract(archive, folder): root = folder.resolve() with zipfile.ZipFile(archive) as bundle: for entry in bundle.infolist(): target = (root / entry.filename).resolve() if target != root and root not in target.parents: raise ValueError("Некорректный путь в архиве компонента.") bundle.extract(entry, root) def install(key, progress, workspace=None): if manifest(): raise ValueError("Нажмите «Обновить компоненты» для установки новых версий из интернета или «Восстановить» для переустановки встроенного комплекта.") if key not in {"node", "ffmpeg", "yt_dlp", "ejs"}: raise ValueError("Неизвестный компонент.") runtime = ROOT / ".runtime" runtime.mkdir(exist_ok=True) cfg = config() with tempfile.TemporaryDirectory(prefix=".ytdl-install-", dir=runtime) as temporary: scratch = Path(temporary) if workspace: workspace(str(scratch)) if key in {"yt_dlp", "ejs"}: progress(None, "Устанавливаю Python-пакет") package = "yt-dlp[default]>=2026.8.19" if key == "yt_dlp" else "yt-dlp-ejs" measured = subprocess.run([sys.executable, "-m", "pip", "install", "--disable-pip-version-check", "--upgrade", package], stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8", errors="replace", creationflags=NO_WINDOW, timeout=600) if measured.returncode: raise ValueError("Не удалось установить компонент. " + measured.stderr[-1200:]) else: archive = scratch / "component.zip" if key == "node": with urllib.request.urlopen("https://nodejs.org/dist/index.json", timeout=30) as response: versions = json.load(response) release = next(item["version"] for item in versions if item.get("lts") and "win-x64-zip" in item.get("files", []) and re.fullmatch(r"v\d+\.\d+\.\d+", item["version"]) and int(item["version"].split(".")[0][1:]) >= 22) fetch(f"https://nodejs.org/dist/{release}/node-{release}-win-x64.zip", archive, progress) destination = runtime / "tools" / "node" else: fetch("https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip", archive, progress) destination = runtime / "tools" / "ffmpeg" progress(80, "Распаковываю компонент") extract(archive, destination) # Newer versions may coexist; choose the executable from this archive. with zipfile.ZipFile(archive) as bundle: relative = next(name for name in bundle.namelist() if name.endswith("/node.exe" if key == "node" else "/bin/ffmpeg.exe")) executable = destination / relative cfg[key] = str(executable.resolve()) if key == "ffmpeg": cfg["ffprobe"] = str(executable.with_name("ffprobe.exe").resolve()) progress(95, "Проверяю компонент") runtime.joinpath("config.json").write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") measured = next(item for item in status() if item["id"] == key) if measured["status"] != "installed": raise ValueError("Компонент установлен, но проверка запуска не прошла.") progress(100, "Установка завершена") return {"title": LABELS[key], "dependency": key, "version": measured["version"]} def main(): key = sys.argv[1] result = install(key, lambda percent, message: print(f"{message}: {percent if percent is not None else '…'}%", flush=True)) print(json.dumps(result, ensure_ascii=False)) if __name__ == "__main__": main()