#!/usr/bin/env python3 """Enable encrypted (commercial) DVD-Video playback. Most retail discs are CSS encrypted. Debian cannot ship the decryption library, so libdvdcss has to be built on the machine from the contrib package libdvd-pkg. That needs the contrib component enabled, which this does for either APT source format: classic /etc/apt/sources.list and /etc/apt/sources.list.d/*.list deb http://deb.debian.org/debian/ trixie main non-free-firmware deb822 /etc/apt/sources.list.d/*.sources Components: main non-free-firmware Only existing Debian entries are edited, and only by adding the missing component, so running this repeatedly cannot produce a duplicate repository or a duplicate component. sudo classroom-dvd-css-setup # set this machine up now classroom-dvd-css-setup --status # report without changing anything Unencrypted discs — church curriculum, home-burned video — play without any of this. """ import argparse import glob import os import re import subprocess import sys from pathlib import Path sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") sys.path.insert( 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"), ) from classroom_config import enabled, read_device_config # noqa: E402 # Tests point this at a fixture tree instead of the live system. APT_ROOT = Path(os.environ.get("CLASSROOM_APT_ROOT", "/")) COMPONENT = "contrib" # A Debian entry is one served from debian.org or from a path with a # /debian segment, which covers local mirrors. Deliberately narrow: the # Google Chrome repository must never be touched. DEBIAN_URI = re.compile( r"^\w+://[^/]*debian\.org(/|$)|^\w+://[^/]*/[^ ]*\bdebian\b", re.IGNORECASE, ) CLASSIC_LINE = re.compile( r"^(?P\s*)" r"(?Pdeb|deb-src)" r"(?P\s+\[[^\]]*\])?" r"(?P\s+)" r"(?P[^#]*?)" r"(?P\s*#.*)?$" ) def log(message): print(f"[classroom-dvd-css-setup] {message}", flush=True) def sources_list(): return APT_ROOT / "etc/apt/sources.list" def sources_directory(): return APT_ROOT / "etc/apt/sources.list.d" def is_debian(uri): return bool(DEBIAN_URI.match(uri)) def rewrite_classic(text): """Add contrib to Debian entries in a classic sources.list. Returns the new text, whether anything changed, and whether a Debian entry already carried the component. """ lines = text.splitlines(keepends=True) changed = False present = False for index, line in enumerate(lines): ending = "\n" if line.endswith("\n") else "" match = CLASSIC_LINE.match(line.rstrip("\n")) if not match: continue fields = match.group("body").split() # uri, suite, then one or more components. A flat repository # (no components) has nothing to add. if len(fields) < 3: continue uri = fields[0] components = fields[2:] if not is_debian(uri): continue if COMPONENT in components: present = True continue if "main" not in components: continue components.append(COMPONENT) lines[index] = "".join( [ match.group("indent"), match.group("type"), match.group("options") or "", match.group("gap"), " ".join([uri, fields[1]] + components), match.group("comment") or "", ending, ] ) changed = True return "".join(lines), changed, present def rewrite_deb822(text): """Add contrib to the Components of Debian stanzas in a .sources file. Returns the new text, whether anything changed, and whether a Debian stanza already carried the component. """ lines = text.splitlines(keepends=True) changed = False present = False # Stanza boundaries are blank lines. stanza_start = 0 stanzas = [] for index, line in enumerate(lines + ["\n"]): if line.strip(): continue if index > stanza_start: stanzas.append((stanza_start, index)) stanza_start = index + 1 for start, end in stanzas: uris = None components_index = None for index in range(start, end): field, separator, value = lines[index].partition(":") if not separator: continue key = field.strip().lower() if key == "uris": uris = value.split() elif key == "components": components_index = index if components_index is None or not uris: continue if not any(is_debian(uri) for uri in uris): continue field, _, value = lines[components_index].partition(":") components = value.split() if COMPONENT in components: present = True continue if "main" not in components: continue components.append(COMPONENT) ending = "\n" if lines[components_index].endswith("\n") else "" lines[components_index] = f"{field}: {' '.join(components)}{ending}" changed = True return "".join(lines), changed, present def source_files(): """Every APT source file, newest format first.""" found = [] directory = sources_directory() if directory.is_dir(): found.extend(sorted(directory.glob("*.sources"))) found.extend(sorted(directory.glob("*.list"))) if sources_list().is_file(): found.append(sources_list()) return found def enable_contrib(): """Enable contrib in the existing Debian sources. Idempotent.""" touched = [] for path in source_files(): try: original = path.read_text() except OSError as error: log(f"skipping {path}: {error}") continue if path.suffix == ".sources": updated, changed, _ = rewrite_deb822(original) else: updated, changed, _ = rewrite_classic(original) if not changed: continue path.write_text(updated) touched.append(path) if touched: for path in touched: log(f"enabled {COMPONENT} in {path}") else: log(f"{COMPONENT} was already enabled") return touched def contrib_available(): """True when some Debian entry already carries the contrib component.""" for path in source_files(): try: text = path.read_text() except OSError: continue if path.suffix == ".sources": _, _, present = rewrite_deb822(text) else: _, _, present = rewrite_classic(text) if present: return True return False def libdvdcss_installed(): # ldconfig lives in /sbin, which is not on an ordinary user's PATH. for candidate in ("/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"): try: result = subprocess.run( [candidate, "-p"], capture_output=True, text=True, ) except OSError: continue return "libdvdcss.so.2" in result.stdout return any( Path(directory, "libdvdcss.so.2").exists() for directory in ("/usr/lib/x86_64-linux-gnu", "/usr/lib", "/lib") ) def apt(*arguments): environment = dict(os.environ) environment["DEBIAN_FRONTEND"] = "noninteractive" return subprocess.run( [ "apt-get", # An OTA install or the update timer may hold the lock. # Waiting is far better than failing the provisioning run. "-o", "DPkg::Lock::Timeout=600", *arguments, ], env=environment, ).returncode def preseed(): """Answer libdvd-pkg's debconf questions so nothing blocks on a prompt.""" answers = "\n".join( [ "libdvd-pkg libdvd-pkg/build boolean true", "libdvd-pkg libdvd-pkg/post-invoke_hook-install boolean true", "libdvd-pkg libdvd-pkg/post-invoke_hook-remove boolean true", ] ) subprocess.run( ["debconf-set-selections"], input=answers + "\n", text=True, ) def install(): log("updating package lists") if apt("update") != 0: log("apt-get update failed; is this machine online?") return 1 preseed() log("installing libdvd-pkg") if apt("install", "-y", "libdvd-pkg") != 0: log("could not install libdvd-pkg") return 1 log("building libdvdcss") environment = dict(os.environ) environment["DEBIAN_FRONTEND"] = "noninteractive" subprocess.run( ["dpkg-reconfigure", "-f", "noninteractive", "libdvd-pkg"], env=environment, ) if not libdvdcss_installed(): log("libdvdcss was not built; commercial discs will not play") return 1 log("libdvdcss installed; commercial discs should now play") return 0 def status(): print(f"contrib enabled: {contrib_available()}") print(f"libdvdcss: {'installed' if libdvdcss_installed() else 'missing'}") config = read_device_config() print(f"optical drive: {', '.join(sorted(glob.glob('/dev/sr[0-9]*'))) or 'none'}") return 0 if libdvdcss_installed() else 1 def main(): parser = argparse.ArgumentParser( description="Enable commercial DVD playback on this classroom.", ) parser.add_argument( "--status", action="store_true", help="Report the current state without changing anything", ) parser.add_argument( "--enable-contrib-only", action="store_true", help="Only enable the contrib component, install nothing", ) parser.add_argument( "--if-enabled", action="store_true", help="Do nothing unless this machine has an optical drive", ) arguments = parser.parse_args() if arguments.status: return status() if arguments.if_enabled: # Ask the machine rather than a setting. A computer that moves # between rooms takes its drive with it, and nobody remembers to # edit a file when it does. if not glob.glob("/dev/sr[0-9]*"): log("this classroom has no optical drive; nothing to do") return 0 # Only the real system needs privilege. Pointed at a fixture tree # through CLASSROOM_APT_ROOT, this is just a file rewrite. if APT_ROOT == Path("/") and os.geteuid() != 0: log("must be run as root: sudo classroom-dvd-css-setup") return 1 if arguments.enable_contrib_only: enable_contrib() return 0 if libdvdcss_installed(): log("libdvdcss is already installed; nothing to do") return 0 enable_contrib() return install() if __name__ == "__main__": sys.exit(main())