"""Thin wrapper around the venv's yt-dlp binary. Everything that shells out to yt-dlp goes through here so the PATH handling (Deno must be discoverable — see specs.md §3) and the shared extractor args live in one place. """ from __future__ import annotations import json import logging import os import subprocess import urllib.request from pathlib import Path from . import config log = logging.getLogger(__name__) class YtdlpError(RuntimeError): pass def binary() -> Path: return config.VENV_BIN / "yt-dlp" def environment() -> dict[str, str]: """Env for a yt-dlp subprocess. yt-dlp locates the JS runtime by searching PATH, so the venv's bin directory must come first — that is where Deno lives. """ env = dict(os.environ) env["PATH"] = f"{config.VENV_BIN}:{env.get('PATH', '')}" return env def version() -> str: result = subprocess.run( [str(binary()), "--version"], capture_output=True, text=True, env=environment(), timeout=60, ) if result.returncode != 0: raise YtdlpError(result.stderr.strip() or "yt-dlp --version failed") return result.stdout.strip() def extractor_args(pot_provider_url: str) -> list[str]: return [ "--extractor-args", "youtube:player_client=default,mweb", "--extractor-args", f"youtubepot-bgutilhttp:base_url={pot_provider_url}", ] def run_json(args: list[str], timeout: int = 300) -> dict: """Run yt-dlp with -J and parse the single JSON document it prints.""" cmd = [str(binary()), *args] log.debug("yt-dlp %s", " ".join(args)) result = subprocess.run( cmd, capture_output=True, text=True, env=environment(), timeout=timeout ) if result.returncode != 0: raise YtdlpError(first_error(result.stderr) or "yt-dlp failed") try: return json.loads(result.stdout) except json.JSONDecodeError as exc: raise YtdlpError(f"could not parse yt-dlp output: {exc}") from exc def run(args: list[str], timeout: int = 7200) -> subprocess.CompletedProcess: """Run yt-dlp for its side effects, returning the completed process.""" cmd = [str(binary()), *args] log.debug("yt-dlp %s", " ".join(args)) return subprocess.run( cmd, capture_output=True, text=True, env=environment(), timeout=timeout ) def first_error(stderr: str) -> str: for line in (stderr or "").splitlines(): if line.startswith("ERROR:"): return line[len("ERROR:") :].strip() return (stderr or "").strip().splitlines()[-1] if stderr.strip() else "" def pot_provider_ping(base_url: str, timeout: float = 5.0) -> dict: """GET /ping on the bgutil provider. Raises on any failure.""" url = base_url.rstrip("/") + "/ping" request = urllib.request.Request(url, headers={"User-Agent": config.USER_AGENT}) with urllib.request.urlopen(request, timeout=timeout) as response: return json.loads(response.read().decode("utf-8")) def plugin_version() -> str | None: """Installed bgutil plugin version, for comparison against the server's.""" try: from importlib.metadata import version as pkg_version return pkg_version("bgutil-ytdlp-pot-provider") except Exception: # pragma: no cover - only when the plugin is absent return None