-
-
Save jvuori/b595b201dbb6bfdbb95d632efaf6474a to your computer and use it in GitHub Desktop.
A convenient little script to install MakeMKV on Linux + Python wrapper which determines the version number automatically
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S uv run --script | |
| # /// script | |
| # requires-python = ">=3.14" | |
| # dependencies = [ | |
| # "requests>=2.31", | |
| # ] | |
| # /// | |
| """Build and install the latest MakeMKV release from source. | |
| Reads the current version and beta key off the official forum (makemkv.com | |
| itself has been intermittently unreachable), then downloads, builds and | |
| installs the "bin" and "oss" packages. If makemkv.com refuses the download, | |
| falls back to the Wayback Machine's archived copy of the same file. | |
| Usage: | |
| uv run install_makemkv.py | |
| uv reads the dependency block above and takes care of the rest. | |
| """ | |
| import argparse | |
| import re | |
| import subprocess | |
| import sys | |
| import tarfile | |
| import tempfile | |
| from pathlib import Path | |
| import requests | |
| FORUM_VERSION_PAGE = "https://forum.makemkv.com/forum/viewtopic.php?f=3&t=224" | |
| FORUM_BETA_KEY_PAGE = "https://forum.makemkv.com/forum/viewtopic.php?f=5&t=1053" | |
| DOWNLOAD_SITE = "https://www.makemkv.com/download" | |
| WAYBACK_CDX_API = "https://web.archive.org/cdx/search/cdx" | |
| CONFIG_FILE = Path.home() / ".MakeMKV" / "settings.conf" | |
| APT_PACKAGES = [ | |
| "build-essential", | |
| "pkg-config", | |
| "libc6-dev", | |
| "libssl-dev", | |
| "libexpat1-dev", | |
| "libavcodec-dev", | |
| "libgl1-mesa-dev", | |
| "qtbase5-dev", | |
| "zlib1g-dev", | |
| ] | |
| def find_latest_version() -> str: | |
| """Scrape the forum announcement thread for the current MakeMKV version.""" | |
| response = requests.get(FORUM_VERSION_PAGE, timeout=30) | |
| # The server intermittently returns 508 Loop Detected but still serves a | |
| # valid page, so don't treat it as fatal. | |
| if response.status_code != 508: | |
| response.raise_for_status() | |
| match = re.search( | |
| rf'a href="{re.escape(DOWNLOAD_SITE)}/makemkv-bin-(.*?)\.tar\.gz"', response.text | |
| ) | |
| if match is None: | |
| raise RuntimeError("No MakeMKV version found on the forum page.") | |
| return match.group(1) | |
| def find_beta_key() -> str: | |
| """Scrape the forum thread for the current free beta key.""" | |
| response = requests.get(FORUM_BETA_KEY_PAGE, timeout=30) | |
| response.raise_for_status() | |
| match = re.search(r"<code>(.*?)</code>", response.text) | |
| if match is None: | |
| raise RuntimeError("No beta key found on the forum page.") | |
| return match.group(1) | |
| def write_beta_key(beta_key: str) -> None: | |
| """Write the beta key into MakeMKV's settings.conf, replacing any existing key.""" | |
| if CONFIG_FILE.exists(): | |
| content = CONFIG_FILE.read_text() | |
| new_content = re.sub(r'app_Key = ".*?"', f'app_Key = "{beta_key}"', content) | |
| else: | |
| content = "" | |
| new_content = f'app_Key = "{beta_key}"\n' | |
| if content == new_content: | |
| return | |
| CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) | |
| CONFIG_FILE.write_text(new_content) | |
| def _stream_download(url: str, dest: Path) -> None: | |
| with requests.get(url, stream=True, timeout=120) as response: | |
| response.raise_for_status() | |
| with dest.open("wb") as fh: | |
| for chunk in response.iter_content(chunk_size=1 << 16): | |
| fh.write(chunk) | |
| def _find_archived_copy(url: str) -> str | None: | |
| """Find the most recent Wayback Machine snapshot of `url` that actually | |
| captured a successful (HTTP 200) response. | |
| The plain "most recent snapshot" lookup can return a capture taken while | |
| makemkv.com was already down, i.e. a recording of the error page itself | |
| - so this filters the CDX index down to genuinely-successful captures. | |
| """ | |
| response = requests.get( | |
| WAYBACK_CDX_API, | |
| params={"url": url, "output": "json", "filter": "statuscode:200", "limit": -1}, | |
| timeout=60, | |
| ) | |
| response.raise_for_status() | |
| rows = response.json() | |
| if len(rows) < 2: # first row is just the column header | |
| return None | |
| timestamp = rows[1][1] | |
| # The "if_" modifier makes the Wayback Machine serve the raw file | |
| # instead of an HTML page with an injected toolbar. | |
| return f"https://web.archive.org/web/{timestamp}if_/{url}" | |
| def download_package(name: str, version: str, dest_dir: Path) -> Path: | |
| """Download a MakeMKV package, falling back to the Wayback Machine on failure.""" | |
| filename = f"makemkv-{name}-{version}.tar.gz" | |
| dest = dest_dir / filename | |
| url = f"{DOWNLOAD_SITE}/{filename}" | |
| try: | |
| _stream_download(url, dest) | |
| except requests.RequestException as direct_exc: | |
| print(f"Direct download of {filename} failed ({direct_exc}); trying Wayback Machine.", file=sys.stderr) | |
| try: | |
| archive_url = _find_archived_copy(url) | |
| if archive_url is None: | |
| raise RuntimeError(f"No archived copy of {filename} found.") | |
| _stream_download(archive_url, dest) | |
| except (requests.RequestException, RuntimeError) as archive_exc: | |
| raise RuntimeError( | |
| f"Could not obtain {filename} from makemkv.com or the Wayback Machine." | |
| ) from archive_exc | |
| if not tarfile.is_tarfile(dest): | |
| raise RuntimeError(f"Downloaded file {dest} is not a valid tar archive.") | |
| return dest | |
| def extract_package(archive: Path, dest_dir: Path) -> Path: | |
| with tarfile.open(archive) as tar: | |
| top_level_dirs = {Path(member.name).parts[0] for member in tar.getmembers()} | |
| if len(top_level_dirs) != 1: | |
| raise RuntimeError(f"Unexpected archive layout in {archive}.") | |
| tar.extractall(dest_dir) | |
| return dest_dir / top_level_dirs.pop() | |
| def build_and_install(package_dir: Path) -> None: | |
| if (package_dir / "makefile.linux").exists(): | |
| # Pre-1.8.6 layout. | |
| subprocess.run(["make", "-f", "makefile.linux"], cwd=package_dir, check=True) | |
| subprocess.run(["sudo", "make", "-f", "makefile.linux", "install"], cwd=package_dir, check=True) | |
| return | |
| # Post-1.8.6 layout. | |
| if (package_dir / "configure").exists(): | |
| subprocess.run(["./configure"], cwd=package_dir, check=True) | |
| tmp_dir = package_dir / "tmp" | |
| tmp_dir.mkdir(exist_ok=True) | |
| (tmp_dir / "eula_accepted").write_text("accepted") | |
| subprocess.run(["make"], cwd=package_dir, check=True) | |
| subprocess.run(["sudo", "make", "install"], cwd=package_dir, check=True) | |
| def missing_apt_packages() -> list[str]: | |
| missing = [] | |
| for package in APT_PACKAGES: | |
| result = subprocess.run( | |
| ["dpkg-query", "-W", "-f=${Status}", package], | |
| capture_output=True, | |
| text=True, | |
| ) | |
| if "install ok installed" not in result.stdout: | |
| missing.append(package) | |
| return missing | |
| def check_prerequisites() -> None: | |
| """Fail fast with install instructions rather than silently invoking sudo.""" | |
| missing = missing_apt_packages() | |
| if not missing: | |
| return | |
| print("Missing required packages:", ", ".join(missing), file=sys.stderr) | |
| print("Install them first, then re-run this script:", file=sys.stderr) | |
| print(f" sudo apt-get install -y {' '.join(missing)}", file=sys.stderr) | |
| raise SystemExit(1) | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument( | |
| "--version", | |
| dest="version", | |
| help="Install this MakeMKV version instead of scraping it off the forum " | |
| "(useful if the forum page itself becomes unreachable).", | |
| ) | |
| return parser.parse_args() | |
| def main() -> None: | |
| args = parse_args() | |
| check_prerequisites() | |
| # Cache sudo credentials up front (needed later for `make install`) so | |
| # failures happen before any download/build work is done. | |
| subprocess.run(["sudo", "-v"], check=True) | |
| if args.version: | |
| version = args.version | |
| print(f"Using pinned version: {version}") | |
| else: | |
| print(f"Getting version info from {FORUM_VERSION_PAGE}") | |
| version = find_latest_version() | |
| print(f"Found version: {version}") | |
| print(f"Getting beta key from {FORUM_BETA_KEY_PAGE}") | |
| beta_key = find_beta_key() | |
| print(f"Found beta key: {beta_key}") | |
| with tempfile.TemporaryDirectory(prefix="makemkv-") as tmp: | |
| tmp_dir = Path(tmp) | |
| for package_name in ("bin", "oss"): | |
| print(f"Downloading makemkv-{package_name}-{version}...") | |
| archive = download_package(package_name, version, tmp_dir) | |
| print(f"Extracting {archive.name}...") | |
| package_dir = extract_package(archive, tmp_dir) | |
| print(f"Building and installing {package_dir.name}...") | |
| build_and_install(package_dir) | |
| print(f"Writing beta key to {CONFIG_FILE}") | |
| write_beta_key(beta_key) | |
| print("All done.") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment