diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 9ec8d132..7ab8d3ac 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -313,7 +313,8 @@ reports which tools it found: It prints a table grouped by category (Core, DRM, HDR, Subtitle, Player, Network) with a status column: a green check for tools that resolved and a red -cross for those it could not find, plus a summary line such as +cross for those it could not find. A version column shows the version that each +tool reports, or `-` when it reports none. It ends with a summary line such as `All required tools installed ✓` or `Missing required: `. If a required tool shows as missing, `env check` tells you to run diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 36754a14..b315f72a 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -364,10 +364,14 @@ unshackle env COMMAND [ARGS]... ### `env check` ``` -unshackle env check +unshackle env check [--verbose] ``` -Prints a dependency table (Category / Tool / Status / Required / Purpose) that shows which external tools unshackle finds on your `PATH`. It also gives a summary of how many required tools are present. If a required tool is missing, it tells you to run [`unshackle setup`](#setup). +| Option | Description | +|---|---| +| `--verbose` | Adds a Path column with the file that unshackle loads for each tool. Use it to find a tool that you want to replace. | + +Prints a dependency table (Category / Tool / Status / Version / Required / Purpose) that shows which external tools unshackle finds on your `PATH` and the version that each one reports. The version is `-` when a tool does not report one. It also gives a summary of how many required tools are present. If a required tool is missing, it tells you to run [`unshackle setup`](#setup). | Category | Tools | |---|---| diff --git a/tests/core/test_binary_version.py b/tests/core/test_binary_version.py new file mode 100644 index 00000000..8eae5254 --- /dev/null +++ b/tests/core/test_binary_version.py @@ -0,0 +1,61 @@ +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +from unshackle.core import binaries +from unshackle.core.binaries import binary_version, parse_version + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("ffmpeg version n9.0.1-11-ge47273f4d9 Copyright (c) 2000-2026\nbuilt with gcc 15.2.0", "9.0.1"), + ("ffmpeg version 2024-03-07-git-97beb63a66-full_build\n built with gcc 12.2.0", None), + ("ffmpeg version N-113672-g1e2a6dbb46-20240307\n built with gcc 13.2.0", None), + ("packager-linux-x64 version v2.6.1-634af65-release", "2.6.1"), + ("MP4 Decrypter - Version 1.4\n(Bento4 Version 1.6.0.0)", "1.4"), + ("mkvmerge v88.0 ('All I Know') 64-bit", "88.0"), + ("mpv 0.34.1 Copyright 2000-2021\nFFmpeg library versions:\n libavutil 56.70.100", "0.34.1"), + ("mpv 0.34.1 Copyright 2000-2021\nFFmpeg version: 4.4.2", "0.34.1"), + ("\nffmpeg version 7.0.2-static Copyright (c) 2000-2024", "7.0.2"), + ("unknown option: --version\nusage: tool [--listen 127.0.0.1:8080]", None), + ("dovi_tool e617c48", None), + ("CCExtractor 0.93, Carlos Fernandez Sanz\nCCExtractor detailed version info\n\tVersion: 0.93", "0.93"), + ], +) +def test_parse_version(text: str, expected: str | None) -> None: + assert parse_version(text) == expected + + +def test_bare_call_only_for_listed_tools(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[list[str]] = [] + + def run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: + calls.append(cmd) + out = "MP4 Decrypter - Version 1.4\nusage: tool [options]" if len(cmd) == 1 else "ERROR: missing output" + return subprocess.CompletedProcess(cmd, 1, "", out) + + monkeypatch.setattr(binaries.subprocess, "run", run) + + listed, other = Path("/opt/bin/mp4decrypt"), Path("/opt/bin/othertool") + assert binary_version(listed) == "1.4" + assert calls == [[str(listed)]] + + calls.clear() + assert binary_version(other) is None + assert calls == [[str(other), "--version"], [str(other), "-version"]] + + +def test_timeout_stops_the_probe(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[list[str]] = [] + + def run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: + calls.append(cmd) + raise subprocess.TimeoutExpired(cmd, kwargs["timeout"]) + + monkeypatch.setattr(binaries.subprocess, "run", run) + + assert binary_version(Path("/opt/bin/othertool")) is None + assert len(calls) == 1 diff --git a/unshackle/commands/env.py b/unshackle/commands/env.py index fdaf55de..5df44cb4 100644 --- a/unshackle/commands/env.py +++ b/unshackle/commands/env.py @@ -189,7 +189,8 @@ def env() -> None: @env.command() -def check() -> None: +@click.option("--verbose", is_flag=True, default=False, help="Also show the path each tool is loaded from.") +def check(verbose: bool) -> None: """Checks environment for the required dependencies.""" all_deps = get_dependencies() @@ -199,11 +200,14 @@ def check() -> None: missing_required = [] table = listing_table("Environment Dependencies", expand=True, show_lines=True) - table.add_column("Category", style="cyan", width=10) - table.add_column("Tool", style="text", width=16) - table.add_column("Status", justify="center", width=10) + table.add_column("Category", style="cyan", width=8) + table.add_column("Tool", style="text", width=14) + table.add_column("Status", justify="center", width=6) + table.add_column("Version", style="text2", width=8, overflow="ellipsis") table.add_column("Req", justify="center", width=4) table.add_column("Purpose", style="text2", overflow="fold") + if verbose: + table.add_column("Path", style="text2", overflow="fold") last_cat = None for dep in all_deps: @@ -226,7 +230,11 @@ def check() -> None: req = "[red]Y[/red]" if dep["required"] else "[bright_black]-[/bright_black]" - table.add_row(category, dep["name"], status, req, dep["desc"]) + version = binaries.binary_version(path) or "[bright_black]-[/bright_black]" + row = [category, dep["name"], status, version, req, dep["desc"]] + if verbose: + row.append(str(path) if path else "[bright_black]-[/bright_black]") + table.add_row(*row) print_wide(table) diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 8acc48f5..a36b56f8 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -28,6 +28,7 @@ from unshackle.core.api.input_bridge import AuthStatus, InputBridge, NoUserBridg from unshackle.core.api.sanitize import MAX_SESSION_CACHE_KEYS, safe_cache_key, safe_cache_payload, sanitize_log from unshackle.core.api.session_log import SessionLogBuffer, SessionLogMirror, capture_service_logs from unshackle.core.api.session_store import SessionStore +from unshackle.core.binaries import binary_version from unshackle.core.cacher import Cacher from unshackle.core.cdm.detect import cdm_type_stub from unshackle.core.config import config, load_yaml @@ -3596,26 +3597,6 @@ async def refresh_services_handler(request: Optional[web.Request] = None) -> web return handle_api_exception(e, context={"operation": "refresh_services"}, debug_mode=debug_mode) -VERSION_RE = re.compile(r"\d+\.\d+(?:\.\d+)*") - - -def binary_version(path: Any) -> Optional[str]: - """Best-effort version probe of a binary. Returns None when nothing is parseable.""" - import subprocess - - for flag in ("--version", "-version"): - try: - proc = subprocess.run( - [str(path), flag], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5 - ) - except (OSError, subprocess.SubprocessError, ValueError): - return None - match = VERSION_RE.search(proc.stdout or "") or VERSION_RE.search(proc.stderr or "") - if match: - return match.group(0) - return None - - async def env_check_handler(request: Optional[web.Request] = None) -> web.Response: """Answer the environment dependency check request.""" from unshackle.commands.env import get_dependencies @@ -3628,7 +3609,7 @@ async def env_check_handler(request: Optional[web.Request] = None) -> web.Respon { "name": dep["name"], "installed": binary is not None, - "version": binary_version(binary) if binary else None, + "version": binary_version(binary), "required": dep["required"], } ) diff --git a/unshackle/core/binaries.py b/unshackle/core/binaries.py index fc2e6500..09ea21ab 100644 --- a/unshackle/core/binaries.py +++ b/unshackle/core/binaries.py @@ -1,4 +1,6 @@ +import re import shutil +import subprocess # nosec B404 import sys from pathlib import Path from typing import Optional @@ -101,6 +103,55 @@ def get_registered_dependencies() -> list[dict]: return list(_registered_binaries.values()) +DOTTED_NUMBER = r"\d+\.\d+(?:\.\d+)*" +VERSION_RE = re.compile(DOTTED_NUMBER) +LABELLED_VERSION_RE = re.compile(rf"(?i)\bversion[:\s]+[a-z]?({DOTTED_NUMBER})") +VERSION_WORD_RE = re.compile(r"(?i)\bversion\b") +BARE_VERSION_TOOLS = {"mp4decrypt"} + + +def parse_version(text: str) -> Optional[str]: + """Return the version from the first line that says "version" or holds a dotted number. + + That line is the tool's own banner; later lines carry library and compiler versions. + """ + for line in text.splitlines(): + if match := LABELLED_VERSION_RE.search(line): + return match.group(1) + if VERSION_WORD_RE.search(line): + return None + if match := VERSION_RE.search(line): + return match.group(0) + return None + + +def binary_version(path: Optional[Path]) -> Optional[str]: + """Best-effort version probe of a binary. Returns None when nothing is parseable. + + BARE_VERSION_TOOLS run with no arguments because they print their version only in the usage text. + """ + if not path: + return None + bare = path.stem.lower() in BARE_VERSION_TOOLS + for args in [[]] if bare else [["--version"], ["-version"]]: + try: + proc = subprocess.run( # nosec B603 + [str(path), *args], + capture_output=True, + stdin=subprocess.DEVNULL, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + ) + except (OSError, subprocess.SubprocessError, ValueError): + return None + version = parse_version(f"{proc.stdout}\n{proc.stderr}") + if version: + return version + return None + + _services_loaded = False