medias 0.6.1

TV media player on bare DRM/KMS: Vulkan UI plane over libmpv video, for local files, UPnP/DLNA servers and YouTube, controlled by HDMI-CEC remote, evdev or terminal
#!/usr/bin/env python3
"""Cross-build medias for Linux x86_64 and aarch64; write tar.gz and .deb to dist/.

Toolchain: cargo-zigbuild + zig, glibc floor --glibc (default 2.36, bookworm).
libmpv and libgbm are linked against per-target stub libraries generated from
the host's libraries (exported functions, same SONAME); the target system's
real libraries are bound at run time. Debian packages target trixie.
"""

import argparse
import gzip
import hashlib
import io
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import textwrap
import tomllib
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
PACKAGING = ROOT / "packaging"

# Rust triple prefix -> (archive suffix, Debian architecture)
TARGETS = {
    "x86_64-unknown-linux-gnu": ("x86_64", "amd64"),
    "aarch64-unknown-linux-gnu": ("aarch64", "arm64"),
}
# Libraries linked with -l; (link name, host file).
STUB_LIBS = (("mpv", "libmpv.so"), ("gbm", "libgbm.so"))
# DT_NEEDED SONAME -> trixie package.
DEB_PACKAGES = {
    "libc.so.6": "libc6",
    "libm.so.6": "libc6",
    "libdl.so.2": "libc6",
    "libpthread.so.0": "libc6",
    "librt.so.1": "libc6",
    "ld-linux-x86-64.so.2": "libc6",
    "ld-linux-aarch64.so.1": "libc6",
    "libgcc_s.so.1": "libgcc-s1",
    "libmpv.so.2": "libmpv2",
    "libgbm.so.1": "libgbm1",
}
# dlopen()ed at run time, absent from DT_NEEDED.
DEB_EXTRA_DEPENDS = ["libegl1"]
DEB_RECOMMENDS = ["libvulkan1", "mesa-vulkan-drivers", "yt-dlp", "kbd"]
DEB_REVISION = "1"


def run(cmd, **kw):
    print("+", " ".join(map(str, cmd)), flush=True)
    return subprocess.run(cmd, check=True, **kw)


def output(cmd):
    return subprocess.run(cmd, check=True, capture_output=True, text=True).stdout


def need(tool, hint):
    if shutil.which(tool) is None:
        sys.exit(f"dist: {tool} not found ({hint})")


def cargo_meta():
    data = tomllib.loads((ROOT / "Cargo.toml").read_text())["package"]
    return data["name"], data["version"], data["description"]


def source_date_epoch():
    if "SOURCE_DATE_EPOCH" in os.environ:
        return int(os.environ["SOURCE_DATE_EPOCH"])
    try:
        return int(output(["git", "-C", str(ROOT), "log", "-1", "--format=%ct"]).strip())
    except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
        return 0


def maintainer():
    name = os.environ.get("DEBFULLNAME")
    email = os.environ.get("DEBEMAIL")
    try:
        name = name or output(["git", "-C", str(ROOT), "config", "user.name"]).strip()
        email = email or output(["git", "-C", str(ROOT), "config", "user.email"]).strip()
    except (subprocess.CalledProcessError, FileNotFoundError):
        pass
    if not name or not email:
        sys.exit("dist: set DEBFULLNAME and DEBEMAIL (or git user.name/user.email)")
    return f"{name} <{email}>"


# --- stub libraries ---------------------------------------------------------

def host_library(filename):
    dirs = []
    try:
        dirs.append(output(["pkg-config", "--variable=libdir", "mpv"]).strip())
    except (subprocess.CalledProcessError, FileNotFoundError):
        pass
    dirs += ["/usr/lib64", "/usr/lib/x86_64-linux-gnu", "/usr/lib/aarch64-linux-gnu",
             "/usr/lib", "/lib64", "/lib", "/usr/local/lib"]
    for d in dirs:
        path = Path(d) / filename
        if path.exists():
            return path.resolve()
    sys.exit(f"dist: host {filename} not found; it lists the symbols for the stub library")


def soname(path):
    match = re.search(r"\(SONAME\)\s+Library soname: \[([^\]]+)\]", output(["readelf", "-d", str(path)]))
    if not match:
        sys.exit(f"dist: {path} has no SONAME")
    return match.group(1)


def exported_functions(path):
    names = set()
    for line in output(["nm", "-D", "--defined-only", str(path)]).splitlines():
        fields = line.split()
        if len(fields) == 3 and fields[1] in ("T", "W", "i"):
            names.add(fields[2].split("@")[0])
    return sorted(names)


def build_stubs(zig_target, stub_dir):
    """Write lib<name>.so for zig_target into stub_dir."""
    stub_dir.mkdir(parents=True, exist_ok=True)
    for name, filename in STUB_LIBS:
        host = host_library(filename)
        source = stub_dir / f"{name}.c"
        source.write_text("".join(f"void {f}(void) {{}}\n" for f in exported_functions(host)))
        run(["zig", "cc", "-target", zig_target, "-shared", "-fPIC", "-nostdlib",
             f"-Wl,-soname,{soname(host)}", "-o", str(stub_dir / f"lib{name}.so"), str(source)])


# --- build ------------------------------------------------------------------

def build(triple, glibc, features):
    arch = triple.split("-")[0]
    stub_dir = ROOT / "target" / "dist-stubs" / triple
    build_stubs(f"{arch}-linux-gnu.{glibc}", stub_dir)
    env = dict(os.environ)
    env["RUSTFLAGS"] = (env.get("RUSTFLAGS", "") + f" -L native={stub_dir}").strip()
    env["CARGO_PROFILE_RELEASE_STRIP"] = "symbols"
    cmd = ["cargo", "zigbuild", "--release", "--target", f"{triple}.{glibc}"]
    if features:
        cmd += ["--features", features]
    run(cmd, cwd=ROOT, env=env)
    binary = ROOT / "target" / triple / "release" / "medias"
    check_binary(binary, arch)
    return binary


ELF_MACHINE = {"x86_64": "Advanced Micro Devices X86-64", "aarch64": "AArch64"}


def check_binary(binary, arch):
    header = output(["readelf", "-h", str(binary)])
    if ELF_MACHINE[arch] not in header:
        sys.exit(f"dist: {binary} is not {arch}")


def needed_libraries(binary):
    return re.findall(r"\(NEEDED\)\s+Shared library: \[([^\]]+)\]", output(["readelf", "-d", str(binary)]))


def max_glibc(binary):
    versions = re.findall(r"GLIBC_(\d+)\.(\d+)(?:\.(\d+))?", output(["readelf", "-V", str(binary)]))
    best = max((tuple(int(v or 0) for v in ver) for ver in versions), default=(2, 17, 0))
    return ".".join(map(str, best[: 3 if best[2] else 2]))


def deb_depends(binary):
    packages = []
    for lib in needed_libraries(binary):
        if lib not in DEB_PACKAGES:
            sys.exit(f"dist: no Debian package known for {lib} (add it to DEB_PACKAGES)")
        packages.append(DEB_PACKAGES[lib])
    deps = []
    for pkg in packages + DEB_EXTRA_DEPENDS:
        entry = f"libc6 (>= {max_glibc(binary)})" if pkg == "libc6" else pkg
        if entry not in deps:
            deps.append(entry)
    return deps


# --- archives ---------------------------------------------------------------

def tar_info(name, size, mode, mtime, kind=tarfile.REGTYPE):
    info = tarfile.TarInfo(name)
    info.size, info.mode, info.mtime, info.type = size, mode, mtime, kind
    info.uid = info.gid = 0
    info.uname = info.gname = "root"
    return info


def write_tar(path, compression, entries, mtime):
    """entries: [(archive path, bytes or None for a directory, mode)]; parents first."""
    with tarfile.open(path, f"w:{compression}", format=tarfile.GNU_FORMAT) as tar:
        for name, data, mode in entries:
            if data is None:
                tar.addfile(tar_info(name, 0, mode, mtime, tarfile.DIRTYPE))
            else:
                tar.addfile(tar_info(name, len(data), mode, mtime), io.BytesIO(data))


def with_parents(files):
    """files: {path: (bytes, mode)} -> sorted entries including parent directories."""
    dirs = set()
    for path in files:
        parts = path.split("/")[:-1]
        for i in range(1, len(parts) + 1):
            dirs.add("/".join(parts[:i]))
    entries = [(d + "/", None, 0o755) for d in dirs]
    entries += [(p, data, mode) for p, (data, mode) in files.items()]
    return sorted(entries, key=lambda e: e[0])


def ar_archive(members, mtime):
    """System V ar as read by dpkg: 16-byte names without '/', even-aligned members."""
    out = bytearray(b"!<arch>\n")
    for name, data in members:
        out += f"{name:<16}{mtime:<12}{0:<6}{0:<6}{0o100644:<8o}{len(data):<10}`\n".encode()
        out += data
        if len(data) % 2:
            out += b"\n"
    return bytes(out)


def doc_files(prefix):
    return {
        f"{prefix}README.md": ((ROOT / "README.md").read_bytes(), 0o644),
        f"{prefix}LiberationSans-OFL.txt": ((ROOT / "assets" / "LiberationSans-OFL.txt").read_bytes(), 0o644),
    }


def build_tarball(binary, out_dir, name, version, suffix, mtime):
    top = f"{name}-{version}-linux-{suffix}"
    files = {
        f"{top}/medias": (binary.read_bytes(), 0o755),
        f"{top}/LICENSE": ((ROOT / "LICENSE").read_bytes(), 0o644),
        f"{top}/medias@.service": ((PACKAGING / "systemd" / "medias@.service").read_bytes(), 0o644),
        f"{top}/medias.default": ((PACKAGING / "systemd" / "medias.default").read_bytes(), 0o644),
        **doc_files(f"{top}/"),
    }
    path = out_dir / f"{top}.tar.gz"
    write_tar(path, "gz", with_parents(files), mtime)
    return path


def control_text(fields):
    return "".join(f"{k}: {v}\n" for k, v in fields).encode()


def build_deb(binary, out_dir, name, version, description, deb_arch, depends, maint, mtime):
    doc = f"usr/share/doc/{name}/"
    files = {
        "usr/bin/medias": (binary.read_bytes(), 0o755),
        "usr/lib/systemd/system/medias@.service": ((PACKAGING / "systemd" / "medias@.service").read_bytes(), 0o644),
        "etc/default/medias": ((PACKAGING / "systemd" / "medias.default").read_bytes(), 0o644),
        f"{doc}copyright": ((PACKAGING / "debian" / "copyright").read_bytes(), 0o644),
        **doc_files(doc),
    }
    files[f"{doc}changelog.Debian.gz"] = (gzip.compress(changelog(name, version, maint, mtime), mtime=mtime), 0o644)
    data = with_parents({f"./{p}": v for p, v in files.items()})

    short, _, long = description.partition(": ")
    installed_kib = (sum(len(d) for d, _ in files.values()) + 1023) // 1024
    control = control_text([
        ("Package", name),
        ("Version", f"{version}-{DEB_REVISION}"),
        ("Architecture", deb_arch),
        ("Maintainer", maint),
        ("Installed-Size", installed_kib),
        ("Depends", ", ".join(depends)),
        ("Recommends", ", ".join(DEB_RECOMMENDS)),
        ("Section", "video"),
        ("Priority", "optional"),
        ("Description", short + "".join(f"\n {line}" for line in textwrap.wrap(long[:1].upper() + long[1:] + ".", 72))),
    ])
    md5sums = "".join(f"{hashlib.md5(d).hexdigest()}  {p}\n" for p, (d, _) in sorted(files.items())).encode()
    control_files = {
        "./control": (control, 0o644),
        "./md5sums": (md5sums, 0o644),
        "./conffiles": (b"/etc/default/medias\n", 0o644),
    }
    for script in ("postinst", "prerm", "postrm"):
        control_files[f"./{script}"] = ((PACKAGING / "debian" / script).read_bytes(), 0o755)

    with tempfile.TemporaryDirectory() as tmp:
        control_tar, data_tar = Path(tmp) / "control.tar.xz", Path(tmp) / "data.tar.xz"
        write_tar(control_tar, "xz", [("./", None, 0o755)] + sorted(
            [(p, d, m) for p, (d, m) in control_files.items()]), mtime)
        write_tar(data_tar, "xz", data, mtime)
        deb = ar_archive([
            ("debian-binary", b"2.0\n"),
            ("control.tar.xz", control_tar.read_bytes()),
            ("data.tar.xz", data_tar.read_bytes()),
        ], mtime)
    path = out_dir / f"{name}_{version}-{DEB_REVISION}_{deb_arch}.deb"
    path.write_bytes(deb)
    return path


def changelog(name, version, maint, mtime):
    from email.utils import formatdate
    return (f"{name} ({version}-{DEB_REVISION}) trixie; urgency=medium\n\n"
            f"  * Release {version}.\n\n"
            f" -- {maint}  {formatdate(mtime)}\n").encode()


def write_sha256(path):
    digest = hashlib.sha256(path.read_bytes()).hexdigest()
    Path(f"{path}.sha256").write_text(f"{digest}  {path.name}\n")


# --- main -------------------------------------------------------------------

def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--target", action="append", choices=sorted(TARGETS),
                        help="Rust target triple (repeatable; default: all)")
    parser.add_argument("--glibc", default="2.36", help="minimum glibc version (default: 2.36)")
    parser.add_argument("--features", default="", help="cargo features, e.g. youtube-tv")
    parser.add_argument("--no-deb", action="store_true", help="skip Debian packages")
    parser.add_argument("--out", type=Path, default=ROOT / "dist", help="output directory")
    args = parser.parse_args(argv)

    for tool, hint in (("cargo-zigbuild", "cargo install cargo-zigbuild"),
                       ("zig", "https://ziglang.org/download/"),
                       ("readelf", "binutils"), ("nm", "binutils"), ("rustup", "https://rustup.rs")):
        need(tool, hint)

    name, version, description = cargo_meta()
    mtime = source_date_epoch()
    maint = None if args.no_deb else maintainer()
    args.out.mkdir(parents=True, exist_ok=True)
    targets = args.target or list(TARGETS)
    run(["rustup", "target", "add", *targets])

    written = []
    for triple in targets:
        suffix, deb_arch = TARGETS[triple]
        print(f"--- {triple}", flush=True)
        binary = build(triple, args.glibc, args.features)
        written.append(build_tarball(binary, args.out, name, version, suffix, mtime))
        if not args.no_deb:
            depends = deb_depends(binary)
            written.append(build_deb(binary, args.out, name, version, description,
                                     deb_arch, depends, maint, mtime))
    for path in written:
        write_sha256(path)
        print(f"wrote {path.relative_to(ROOT) if path.is_relative_to(ROOT) else path}")
    import index  # after dist's own definitions: index imports this module
    print(f"wrote {index.write(args.out)}")


if __name__ == "__main__":
    main()