329 lines
13 KiB
Python
329 lines
13 KiB
Python
"""Linux distro detection and per-distro profile registry.
|
|
|
|
Replaces the v1 250-line if/elif chain with a tuple-of-dataclasses
|
|
table (``DISTRO_REGISTRY``). Adding a new distro is a one-row change.
|
|
Pure stdlib; no internal package dependencies.
|
|
"""
|
|
|
|
import os
|
|
import platform
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
# ──────────────────────────────────────────────
|
|
# DISTRO DETECTION & PROFILES
|
|
# ──────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class DistroProfile:
|
|
family: str # Canonical family: arch, debian, redhat, suse, nixos, unknown
|
|
name: str # Pretty name: "Arch Linux", "Fedora 40", etc.
|
|
version_id: str # e.g. "40", "15.6", "24.05"
|
|
pkg_manager: str # e.g. "pacman", "dnf", "zypper", "apt", "nix"
|
|
install_cmd_template: str # e.g. "sudo pacman -S {packages}"
|
|
binary_extra_paths: list[str] # Distro-specific dirs to search for binaries
|
|
av1an_known_encoder_names: list[str] # Names this distro's av1an build may accept
|
|
ffmpeg_pkg: str # Package name providing ffmpeg
|
|
av1an_pkg: str # Package name providing av1an
|
|
notes: str # Distro-specific quirks worth showing the user
|
|
# Runtime dependency packages (key = generic name, value = distro package name)
|
|
dep_pkgs: dict[str, str] = field(default_factory=dict)
|
|
# Binaries that av1an invokes directly (not via ffmpeg)
|
|
encoder_binaries: dict[str, list[str]] = field(default_factory=dict)
|
|
# VSScript package name — on most distros this is bundled into 'vapoursynth',
|
|
# but Debian/Ubuntu split it into a separate -script-dev package.
|
|
# If set, this takes priority over dep_pkgs["vapoursynth"] for the VS check.
|
|
vsscript_pkg: str = ""
|
|
|
|
|
|
def _read_os_release() -> dict[str, str]:
|
|
"""Parse /etc/os-release into a dict. Falls back to empty dict."""
|
|
os_release = Path("/etc/os-release")
|
|
fallback = Path("/usr/lib/os-release")
|
|
target = os_release if os_release.exists() else fallback
|
|
if not target.exists():
|
|
return {}
|
|
data = {}
|
|
for line in target.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
line = line.strip()
|
|
if "=" in line and not line.startswith("#"):
|
|
key, _, val = line.partition("=")
|
|
data[key.strip()] = val.strip().strip('"')
|
|
return data
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# DISTRO_REGISTRY — data-driven distro detection (v3, OTC-014).
|
|
#
|
|
# v1/v2 had a 250-line if/elif chain in detect_distro() with one branch per
|
|
# distro family. Each branch constructed a DistroProfile with mostly-identical
|
|
# fields — a classic SEI CERT MSC04-C violation (no single source of truth).
|
|
#
|
|
# v3 collapses the chain into a tuple-of-dicts table. Each entry has:
|
|
# ids: tuple of distro_id strings that match this family
|
|
# id_likes: tuple of ID_LIKE substrings that also match this family
|
|
# family: canonical family name
|
|
# pkg_manager: package manager binary name
|
|
# install_cmd: template with {packages} placeholder
|
|
# extra_paths: list of distro-specific binary search paths
|
|
# dep_pkgs: map of generic name -> distro package name
|
|
# notes: distro-specific quirks string
|
|
# vsscript_pkg: (optional) separate VSScript package name
|
|
#
|
|
# Adding a new distro is now a single-table-row change — no code modification.
|
|
# The encoder_binaries field is identical across all distros and lives in the
|
|
# function body (it's the same dict literal every time).
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
# encoder_binaries is identical for every distro — define once.
|
|
_ENCODER_BINARIES: dict[str, list[str]] = {
|
|
"svt_av1": ["SvtAv1EncApp", "svt_av1"],
|
|
"vpx": ["vpxenc"],
|
|
"x265": ["x265"],
|
|
}
|
|
|
|
# Common av1an encoder names known across distros.
|
|
_AV1AN_KNOWN_ENCODERS: list[str] = ["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _DistroEntry:
|
|
"""One row in the DISTRO_REGISTRY table."""
|
|
ids: tuple[str, ...] # exact distro_id matches
|
|
id_likes: tuple[str, ...] # ID_LIKE substring matches
|
|
family: str
|
|
pkg_manager: str
|
|
install_cmd: str # template with {packages}
|
|
extra_paths: tuple[str, ...]
|
|
dep_pkgs: dict[str, str]
|
|
notes: str
|
|
vsscript_pkg: str = ""
|
|
|
|
|
|
DISTRO_REGISTRY: tuple[_DistroEntry, ...] = (
|
|
_DistroEntry(
|
|
ids=("arch", "manjaro", "endeavouros", "garuda", "cachyos"),
|
|
id_likes=("arch",),
|
|
family="arch",
|
|
pkg_manager="pacman",
|
|
install_cmd="sudo pacman -S {packages}",
|
|
extra_paths=("/usr/bin", "/usr/local/bin", "~/.local/bin", "~/.cargo/bin"),
|
|
dep_pkgs={
|
|
"vapoursynth": "vapoursynth",
|
|
"svt-av1": "svt-av1",
|
|
"x265": "x265",
|
|
"vpx": "libvpx",
|
|
"opus": "libopus",
|
|
"vorbis": "libvorbis",
|
|
"flac": "flac",
|
|
},
|
|
notes=(
|
|
"Arch/Manjaro: av1an is in the AUR (yay -S av1an) or community repo. "
|
|
"SVT-AV1 encoder name is typically 'svt_av1'. "
|
|
"Cargo-installed av1an may live in ~/.cargo/bin."
|
|
),
|
|
),
|
|
_DistroEntry(
|
|
ids=("fedora",),
|
|
id_likes=("fedora",),
|
|
family="redhat",
|
|
pkg_manager="dnf",
|
|
install_cmd="sudo dnf install {packages}",
|
|
extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
|
|
dep_pkgs={
|
|
"vapoursynth": "vapoursynth",
|
|
"svt-av1": "svt-av1",
|
|
"x265": "x265",
|
|
"vpx": "libvpx-tools",
|
|
"opus": "opus",
|
|
"vorbis": "libvorbis",
|
|
"flac": "flac",
|
|
},
|
|
notes=(
|
|
"Fedora: av1an may require COPR enablement first: "
|
|
"sudo dnf copr enable sergiomb/av1an (or build from source). "
|
|
"SVT-AV1 is in the main repos as 'svt-av1'. "
|
|
"Ensure RPM Fusion is enabled for full codec support."
|
|
),
|
|
),
|
|
_DistroEntry(
|
|
ids=("rhel", "centos", "rocky", "almalinux", "ol"),
|
|
id_likes=("rhel", "centos"),
|
|
family="redhat",
|
|
# RHEL-family: dnf if present, fall back to yum
|
|
pkg_manager="", # resolved at runtime in detect_distro()
|
|
install_cmd="", # resolved at runtime in detect_distro()
|
|
extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
|
|
dep_pkgs={
|
|
"vapoursynth": "vapoursynth",
|
|
"svt-av1": "svt-av1",
|
|
"x265": "x265",
|
|
"vpx": "libvpx-tools",
|
|
"opus": "opus",
|
|
"vorbis": "libvorbis",
|
|
"flac": "flac",
|
|
},
|
|
notes=(
|
|
"RHEL/CentOS/Rocky/Alma: av1an is NOT in default repos. "
|
|
"Options: (1) cargo install av1an, (2) build from GitHub source, "
|
|
"(3) use pre-built binary from releases. "
|
|
"Enable EPEL + RPM Fusion for FFmpeg codec support."
|
|
),
|
|
),
|
|
_DistroEntry(
|
|
ids=("opensuse-leap", "opensuse-tumbleweed", "sles"),
|
|
id_likes=("suse",),
|
|
family="suse",
|
|
pkg_manager="zypper",
|
|
install_cmd="sudo zypper install {packages}",
|
|
extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
|
|
dep_pkgs={
|
|
"vapoursynth": "vapoursynth",
|
|
"svt-av1": "svt-av1",
|
|
"x265": "x265",
|
|
"vpx": "libvpx",
|
|
"opus": "libopus",
|
|
"vorbis": "libvorbis",
|
|
"flac": "flac",
|
|
},
|
|
notes=(
|
|
"openSUSE: av1an may be available via OBS (Open Build Service). "
|
|
"Check: https://build.opensuse.org/package/show/multimedia:apps/av1an. "
|
|
"Packman repo provides FFmpeg with full codec support."
|
|
),
|
|
),
|
|
_DistroEntry(
|
|
ids=("nixos",),
|
|
id_likes=("nixos",),
|
|
family="nixos",
|
|
pkg_manager="nix",
|
|
install_cmd="nix-shell -p {packages}",
|
|
extra_paths=("/run/current-system/sw/bin", "~/.nix-profile/bin"),
|
|
dep_pkgs={
|
|
"vapoursynth": "vapoursynth",
|
|
"svt-av1": "svt-av1",
|
|
"x265": "x265",
|
|
"vpx": "libvpx",
|
|
"opus": "opus",
|
|
"vorbis": "libvorbis",
|
|
"flac": "flac",
|
|
},
|
|
notes=(
|
|
"NixOS: Use 'nix-shell -p ffmpeg av1an' or add to configuration.nix. "
|
|
"Binaries live under /run/current-system/sw/bin or ~/.nix-profile/bin. "
|
|
"av1an CLI flags may differ from other distros depending on the nixpkgs channel."
|
|
),
|
|
),
|
|
_DistroEntry(
|
|
ids=("debian", "ubuntu", "linuxmint", "pop"),
|
|
id_likes=("debian",),
|
|
family="debian",
|
|
pkg_manager="apt",
|
|
install_cmd="sudo apt install {packages}",
|
|
extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
|
|
dep_pkgs={
|
|
"vapoursynth": "vapoursynth",
|
|
"svt-av1": "svtav1",
|
|
"x265": "x265",
|
|
"vpx": "libvpx-tools",
|
|
"opus": "libopus-dev",
|
|
"vorbis": "libvorbis-dev",
|
|
"flac": "flac",
|
|
},
|
|
notes=(
|
|
"Debian/Ubuntu: av1an is in the repos (apt install av1an). "
|
|
"Debian repo builds may use 'svt' as encoder name instead of 'svt_av1'. "
|
|
"VSScript is in a separate package: libvapoursynth-script-dev. "
|
|
"For newer builds, consider cargo install av1an."
|
|
),
|
|
vsscript_pkg="libvapoursynth-script-dev",
|
|
),
|
|
)
|
|
|
|
|
|
def _match_distro_entry(distro_id: str, id_like: list[str]) -> _DistroEntry | None:
|
|
"""Find the first DISTRO_REGISTRY entry whose ids or id_likes match.
|
|
|
|
SEI CERT MSC04-C spirit: the matching logic is one flat loop over a
|
|
table — no nested if/elif chain. Adding a new distro is a one-line
|
|
table change in DISTRO_REGISTRY above; this function never needs
|
|
modification.
|
|
"""
|
|
for entry in DISTRO_REGISTRY:
|
|
if distro_id in entry.ids:
|
|
return entry
|
|
if any(like in id_like for like in entry.id_likes):
|
|
return entry
|
|
return None
|
|
|
|
|
|
def detect_distro() -> DistroProfile:
|
|
"""
|
|
Detect the running Linux distribution via /etc/os-release.
|
|
Returns a DistroProfile with distro-specific package manager,
|
|
install commands, binary search paths, and known quirks.
|
|
|
|
v3 (OTC-014): the per-distro data lives in DISTRO_REGISTRY above.
|
|
This function is now ~30 lines of glue instead of a 250-line
|
|
if/elif chain.
|
|
"""
|
|
info = _read_os_release()
|
|
id_like = info.get("ID_LIKE", "").lower().split()
|
|
distro_id = info.get("ID", "").lower()
|
|
pretty = info.get("PRETTY_NAME", info.get("NAME", platform.system()))
|
|
version = info.get("VERSION_ID", "?")
|
|
|
|
entry = _match_distro_entry(distro_id, id_like)
|
|
|
|
if entry is None:
|
|
# Fallback: unknown distro
|
|
return DistroProfile(
|
|
family="unknown",
|
|
name=pretty,
|
|
version_id=version,
|
|
pkg_manager="unknown",
|
|
install_cmd_template="# Unknown distro — install ffmpeg and av1an manually",
|
|
binary_extra_paths=["/usr/bin", "/usr/local/bin", "~/.cargo/bin", "~/.local/bin"],
|
|
av1an_known_encoder_names=list(_AV1AN_KNOWN_ENCODERS),
|
|
ffmpeg_pkg="ffmpeg",
|
|
av1an_pkg="av1an",
|
|
dep_pkgs={},
|
|
encoder_binaries=dict(_ENCODER_BINARIES),
|
|
notes="Unknown distro detected. Ensure ffmpeg and av1an are in PATH.",
|
|
)
|
|
|
|
# Resolve runtime-determined fields (RHEL family: dnf vs yum)
|
|
pkg_manager = entry.pkg_manager
|
|
install_cmd = entry.install_cmd
|
|
if not pkg_manager:
|
|
# RHEL/CentOS family: pick dnf if installed, else yum
|
|
has_dnf = Path("/usr/bin/dnf").exists()
|
|
pkg_manager = "dnf" if has_dnf else "yum"
|
|
install_cmd = (
|
|
"sudo dnf install {packages}" if has_dnf
|
|
else "sudo yum install {packages}"
|
|
)
|
|
|
|
return DistroProfile(
|
|
family=entry.family,
|
|
name=pretty,
|
|
version_id=version,
|
|
pkg_manager=pkg_manager,
|
|
install_cmd_template=install_cmd,
|
|
binary_extra_paths=list(entry.extra_paths),
|
|
av1an_known_encoder_names=(
|
|
# Arch family includes the additional 'svt-av1' alias
|
|
["svt_av1", "svt", "svt-av1", "aom", "rav1e", "vpx", "x265"]
|
|
if entry.family == "arch"
|
|
else list(_AV1AN_KNOWN_ENCODERS)
|
|
),
|
|
ffmpeg_pkg="ffmpeg",
|
|
av1an_pkg="av1an",
|
|
dep_pkgs=dict(entry.dep_pkgs),
|
|
encoder_binaries=dict(_ENCODER_BINARIES),
|
|
vsscript_pkg=entry.vsscript_pkg,
|
|
notes=entry.notes,
|
|
)
|
|
|