diff --git a/README.md b/README.md index 6deb415..7b0ed3c 100644 --- a/README.md +++ b/README.md @@ -111,26 +111,27 @@ Keep values simple (no spaces / shell-special characters). ## Setup (mediapi app) Implemented as a small Flask app (`mediapi/`) + a permanently-running `mpv` -player controlled over its JSON IPC socket. mpv renders video to HDMI -directly (DRM/KMS, no desktop needed); the phone browser only shows -metadata/controls, never the video image itself. +player controlled over its JSON IPC socket. mpv renders video to HDMI; the +phone browser only shows metadata/controls, never the video image itself. -**Dual-HDMI mirroring.** A single mpv on DRM can only drive one connector, and -the Pi's `vc4-kms` driver can't clone two HDMI outputs onto one framebuffer — -so mirroring is done by running *one mpv per connected screen*. -`scripts/start-mpv.py` (launched by the `mediapi-mpv` unit) enumerates the -connected HDMI connectors and starts a **primary** mpv (audio + the app's IPC -socket, `mpv.sock`) plus a **mirror** mpv per extra screen (video-only, on -`mpv-mirror-.sock`). The app plays/pauses/seeks the primary and -echoes those to the mirrors best-effort, re-syncing on every video. One screen -→ same as before; a dead mirror just leaves that screen dark, never the audio -screen. Because each screen runs its own mpv, the same file decodes once per -screen (`--hwdec=auto-safe` falls back to software if the HW decoder is busy). +**Dual-HDMI mirroring.** The Pi 4's two HDMI connectors share a single vc4 DRM +card, and DRM *master* is exclusive per card — so two independent mpv processes +can't both drive a screen (the second gets `Failed to acquire DRM master`). +Instead the `mediapi-mpv` unit runs a minimal **X server** via `xinit` +(`scripts/mediapi-session.sh`): X is the one DRM master, `xrandr --same-as` +clones the first output's framebuffer onto every other connected HDMI output, +and a **single** fullscreen mpv renders into it — so the same frames appear on +both screens, decoded once. mpv carries audio and the app's only IPC socket +(`/run/mediapi/mpv.sock`); `--hwdec=v4l2m2m-copy` uses the Pi's hardware H.264/ +HEVC decoder (~⅓ the CPU of software decoding), falling back to software for +codecs it can't handle. The session waits for connectors to probe as connected +before mirroring, so a cold-boot HDMI race doesn't drop it to one screen. One +screen attached → just that screen, no config needed. ### First-time install (on the Pi) ```bash -# system deps: mpv for HDMI/DRM playback +# system deps: mpv for playback (install.sh installs the minimal X server itself) sudo apt update sudo apt install -y mpv @@ -196,13 +197,18 @@ Notes / things to double check on the actual hardware (couldn't be verified from a dev machine): * `dtoverlay=vc4-kms-v3d` should already be set in `/boot/firmware/config.txt` on current Bookworm Pi4 images (needed for DRM output) — worth a quick check. -* Mirroring picks up whatever HDMI connectors read `connected` under - `/sys/class/drm/card*-HDMI-*/status` at service start — so plug in both - screens **before** `mediapi-mpv` starts (or `sudo systemctl restart - mediapi-mpv` after). Check what it launched with - `sudo journalctl -u mediapi-mpv | grep start-mpv` and confirm both sockets: - `ls -l /run/mediapi/mpv*.sock`. `mpv --drm-connector=help` (with a display - attached) lists the connector names if the sysfs guess is ever wrong. +* Mirroring clones every HDMI output that reads `connected` at session start + onto the first. The session waits for connectors to probe (cold-boot race), + then runs `xrandr --same-as`. See what it did with + `sudo journalctl -u mediapi-mpv | grep mediapi-session`; inspect the outputs + live with `DISPLAY=:0 xrandr` (as the service user). Both screens should share + a common resolution; if they differ, `xrandr` clones at the first output's + mode. Confirm the one socket: `ls -l /run/mediapi/mpv.sock`. +* Non-root X requires `/etc/X11/Xwrapper.config` with `allowed_users=anybody` + (install.sh writes it). If the service crash-loops with `Could not create + server lock file: /tmp/.X0-lock`, a previous X died uncleanly — remove + `/tmp/.X0-lock` (and `/tmp/.X11-unix/X0`) and restart. The X log is at + `~/.local/share/xorg/Xorg.0.log`. * If audio doesn't come out of the TV, check `aplay -l` for the HDMI ALSA device name (usually `vc4-hdmi`) and add e.g. `--audio-device=alsa/plughw:CARD=vc4hdmi0,DEV=0` to the mpv unit template, or diff --git a/install.sh b/install.sh index 9fe1b49..38d87df 100755 --- a/install.sh +++ b/install.sh @@ -117,13 +117,27 @@ deploy_current() { fi # The mpv service runs as MEDIAPI_USER and needs the video+render groups to - # reach the GPU/DRM devices for HDMI output. Idempotent; systemd picks up the - # new membership when it (re)starts the service below, so no logout needed. + # reach the GPU/DRM devices for HDMI output, plus tty+input to own the VT and + # read input under X. Idempotent; systemd picks up the new membership when it + # (re)starts the service below, so no logout needed. if ! id -nG "${MEDIAPI_USER}" | tr ' ' '\n' | grep -qx render; then - echo "==> Adding '${MEDIAPI_USER}' to video,render groups ..." - sudo usermod -aG video,render "${MEDIAPI_USER}" + echo "==> Adding '${MEDIAPI_USER}' to video,render,input,tty groups ..." + sudo usermod -aG video,render,input,tty "${MEDIAPI_USER}" fi + # The player runs a minimal X server so a single mpv can be mirrored across + # both HDMI outputs (one DRM master; two separate mpv on the shared vc4 card + # cannot -- see scripts/mediapi-session.sh). Install just the X server + xinit + # + xrandr, and allow the non-root service user to start X on its VT. + if ! command -v Xorg >/dev/null 2>&1; then + echo "==> Installing minimal X server (xserver-xorg-core, xinit, xrandr) ..." + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + xserver-xorg-core xinit x11-xserver-utils + fi + echo "==> Configuring /etc/X11/Xwrapper.config (allow non-root X on the VT) ..." + printf 'allowed_users=anybody\nneeds_root_rights=yes\n' \ + | sudo tee /etc/X11/Xwrapper.config >/dev/null + echo "==> Applying WiFi country + AP config ..." sudo raspi-config nonint do_wifi_country "${MEDIAPI_WIFI_COUNTRY}" if nmcli -g NAME con show | grep -qx "${MEDIAPI_AP_CONN_NAME}"; then diff --git a/mediapi/__init__.py b/mediapi/__init__.py index c742a8f..d3b23be 100644 --- a/mediapi/__init__.py +++ b/mediapi/__init__.py @@ -21,7 +21,6 @@ def create_app(): app.player = PlayerStateManager( app.config["MPV_SOCKET"], app.config["VIDEO_EXTENSIONS"], - mirror_glob=app.config.get("MPV_MIRROR_GLOB"), ) app.player.start() diff --git a/mediapi/config.py b/mediapi/config.py index 7e1ca26..7464d82 100644 --- a/mediapi/config.py +++ b/mediapi/config.py @@ -75,11 +75,9 @@ class Config: MEDIA_ROOTS = [ p for p in os.environ.get("MEDIAPI_MEDIA_ROOTS", "/localmedia").split(":") if p ] + # A single mpv drives every HDMI output -- the X session mirrors the screens + # with xrandr (see scripts/mediapi-session.sh) -- so there is one socket. MPV_SOCKET = os.environ.get("MEDIAPI_MPV_SOCKET", "/run/mediapi/mpv.sock") - # Extra mpv instances (one per additional HDMI screen) expose sockets named - # mpv-mirror-.sock alongside the primary socket. The player - # discovers them by glob and echoes playback commands to them best-effort. - MPV_MIRROR_GLOB = os.path.join(os.path.dirname(MPV_SOCKET), "mpv-mirror-*.sock") PORT = int(os.environ.get("MEDIAPI_PORT", "8080")) VIDEO_EXTENSIONS = { diff --git a/mediapi/player.py b/mediapi/player.py index ed8596e..11435f0 100644 --- a/mediapi/player.py +++ b/mediapi/player.py @@ -1,4 +1,3 @@ -import glob import logging import os import threading @@ -19,17 +18,12 @@ class PlayerStateManager: Flask request handlers only ever read the cached snapshot or send a command through this class -- they never touch the socket directly.""" - def __init__(self, socket_path, video_extensions, mirror_glob=None): + def __init__(self, socket_path, video_extensions): self.socket_path = socket_path self.video_extensions = video_extensions - # Sockets of any per-screen "mirror" mpv instances (see start-mpv.py). - # We only ever push playback commands to these best-effort; the primary - # socket above is the sole source of state and the one that carries - # audio, so a missing/broken mirror just means one screen is dark. - self._mirror_glob = mirror_glob - self._mirror_clients = {} - self._mirror_lock = threading.Lock() - + # A single mpv drives every HDMI output (the X session mirrors the + # screens with xrandr -- see scripts/mediapi-session.sh), so there is + # exactly one socket to talk to. self._client = MpvIPCClient(socket_path) self._lock = threading.Lock() self._stop = threading.Event() @@ -137,7 +131,6 @@ class PlayerStateManager: self._client.command("loadfile", next_file, "replace") except MpvIPCError as exc: log.warning("auto-advance loadfile failed: %s", exc) - self._broadcast("loadfile", next_file, "replace") # -- public read API --------------------------------------------------- @@ -147,37 +140,10 @@ class PlayerStateManager: state["keep_playing"] = self._state["keep_playing"] return state - # -- mirror screens (best-effort) -------------------------------------- - - def _broadcast(self, *command_args): - """Echo a playback command to every mirror mpv, best-effort. Never - raises: a mirror that's absent, mid-restart, or wedged must not affect - the primary screen or the HTTP response. Mirrors are re-synced on every - loadfile, so a command missed here self-heals at the next video.""" - if not self._mirror_glob: - return - with self._mirror_lock: - paths = set(glob.glob(self._mirror_glob)) - # Drop clients whose socket disappeared (display unplugged). - for gone in set(self._mirror_clients) - paths: - self._mirror_clients.pop(gone).close() - for path in paths: - client = self._mirror_clients.get(path) - if client is None: - client = self._mirror_clients[path] = MpvIPCClient(path) - try: - if not client.connected: - client.connect() - client.command(*command_args) - except (OSError, MpvIPCError) as exc: - client.close() - log.debug("mirror %s command failed: %s", path, exc) - # -- public command API (called from Flask routes) --------------------- def play_file(self, path): self._client.command("loadfile", path, "replace") - self._broadcast("loadfile", path, "replace") # Build the auto-advance queue from the containing folder, positioned # at the file just started -- so "keep playing" continues through the # rest of the folder even when you start from the middle. @@ -199,7 +165,6 @@ class PlayerStateManager: if not files: raise ValueError(f"no video files in {folder_path}") self._client.command("loadfile", files[0], "replace") - self._broadcast("loadfile", files[0], "replace") with self._lock: self._queue_folder = folder_path self._queue_files = files @@ -207,11 +172,9 @@ class PlayerStateManager: def playpause(self): self._client.command("cycle", "pause") - self._broadcast("cycle", "pause") def seek(self, offset_seconds): self._client.command("seek", offset_seconds, "relative") - self._broadcast("seek", offset_seconds, "relative") def set_volume(self, value): value = max(0, min(100, value)) diff --git a/scripts/mediapi-session.sh b/scripts/mediapi-session.sh new file mode 100755 index 0000000..97284c1 --- /dev/null +++ b/scripts/mediapi-session.sh @@ -0,0 +1,78 @@ +#!/bin/sh +# +# mediapi X session -- the X client launched by xinit from mediapi-mpv.service. +# +# Mirrors every extra HDMI output onto the first, then runs ONE fullscreen mpv. +# +# Why an X server at all (we used to run mpv straight on DRM/KMS for fast boot): +# DRM "master" is exclusive per card. The Pi 4's two HDMI connectors live on a +# single vc4 card, so two independent mpv processes cannot both modeset -- the +# second gets "Failed to acquire DRM master: Permission denied" and shows +# nothing. A single X server is the one DRM master and drives BOTH connectors; +# `xrandr --same-as` clones the first output's framebuffer onto the others, so +# one mpv appears on every screen. See git history for the full diagnosis. +# +set -u + +RUNTIME_DIR="${MEDIAPI_RUNTIME_DIR:-/run/mediapi}" +SOCKET="${MEDIAPI_MPV_SOCKET:-$RUNTIME_DIR/mpv.sock}" + +# Always-on car display: never blank or DPMS-off. +xset -dpms 2>/dev/null || true +xset s off 2>/dev/null || true + +connected() { xrandr --query 2>/dev/null | awk '/ connected/{print $1}'; } + +# Cold-boot race: the second HDMI connector may not be probed as "connected" +# when X starts. Wait for connected outputs to appear, then let the set settle +# so a screen that lights up a beat later is still mirrored (bounded so a +# genuinely single-screen setup doesn't hang the boot). +deadline=$(( $(date +%s) + 30 )) +outs="$(connected)" +while [ -z "$outs" ] && [ "$(date +%s)" -lt "$deadline" ]; do + sleep 1 + outs="$(connected)" +done + +stable=0 +settle_end=$(( $(date +%s) + 8 )) +while [ "$(date +%s)" -lt "$settle_end" ]; do + sleep 1 + cur="$(connected)" + if [ "$cur" != "$outs" ]; then + outs="$cur" + stable=0 + else + stable=$(( stable + 1 )) + [ "$stable" -ge 2 ] && break + fi +done + +primary="" +rest="" +for o in $outs; do + if [ -z "$primary" ]; then primary="$o"; else rest="$rest $o"; fi +done +echo "mediapi-session: connected outputs:$outs (primary=${primary:-none})" + +for o in $rest; do + echo "mediapi-session: mirroring $o onto $primary" + xrandr --output "$o" --same-as "$primary" || true +done + +# Single mpv, on X (so one DRM master drives every mirrored connector). Carries +# audio and the app's IPC socket; the app talks only to this one socket now. +# hwdec=v4l2m2m-copy uses the Pi's hardware H.264/HEVC decoder (falls back to +# software for codecs it can't handle). Roughly a third of the CPU of software +# decoding 1080p -- important for an always-on player in a warm car. The single +# mpv has the HW decoder to itself now, so there's no contention. +exec mpv \ + --idle=yes \ + --vo=gpu-next \ + --gpu-context=x11egl \ + --hwdec=v4l2m2m-copy \ + --fullscreen \ + --no-terminal \ + --keep-open=no \ + --input-ipc-server="$SOCKET" \ + --ao=alsa diff --git a/scripts/start-mpv.py b/scripts/start-mpv.py deleted file mode 100755 index c7481e7..0000000 --- a/scripts/start-mpv.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python3 -"""Launch one mpv per connected HDMI output so playback mirrors across every -attached display. - -mpv's DRM output can only ever drive a single connector, and the Pi's vc4-kms -driver can't clone two HDMI connectors onto one framebuffer -- so "mirror on -both screens" means running one mpv per connector. This script enumerates the -connected connectors at start and launches: - - * a PRIMARY mpv on the first connected connector -- owns audio and the IPC - socket the Flask app talks to (/run/mediapi/mpv.sock); - * a MIRROR mpv on each additional connector -- video only (--ao=null), each - on its own IPC socket (/run/mediapi/mpv-mirror-.sock) so the app - can echo loadfile/pause/seek to it best-effort. - -Design choices, all in service of "must work unattended in a car": - - * If no connector can be detected (unexpected sysfs naming, etc.) it falls - back to a single auto-picking mpv -- i.e. exactly the old behavior, never - worse. - * It supervises the children: if any mpv exits, it tears the rest down and - exits non-zero so systemd restarts the whole unit (which re-enumerates - displays -- e.g. a screen powered on since last start). - * The mirror is strictly best-effort; the primary is what carries audio and - control, so a dead mirror degrades to single-screen playback. - -Runs as a plain stdlib script (no venv needed) so it can start early at boot. -""" - -import glob -import os -import re -import signal -import subprocess -import sys -import time - -RUNTIME_DIR = os.environ.get("MEDIAPI_RUNTIME_DIR", "/run/mediapi") -PRIMARY_SOCKET = os.environ.get("MEDIAPI_MPV_SOCKET", os.path.join(RUNTIME_DIR, "mpv.sock")) - -# Flags shared by every instance. Kept in sync with what the mpv systemd unit -# used to pass directly. -COMMON_ARGS = [ - "/usr/bin/mpv", - "--idle=yes", - "--vo=gpu-next", - "--gpu-context=drm", - "--hwdec=auto-safe", # falls back to software if the HW decoder is busy - "--fullscreen", - "--no-terminal", - "--keep-open=no", -] - - -def connected_hdmi_outputs(): - """Return [(card_device, connector_name), ...] for every connected HDMI - connector, e.g. [("/dev/dri/card1", "HDMI-A-1"), ...], sorted by connector - name so the assignment of primary/mirror is stable across restarts.""" - outputs = [] - for status_path in sorted(glob.glob("/sys/class/drm/card*-HDMI-*/status")): - try: - with open(status_path) as f: - status = f.read().strip() - except OSError: - continue - if status != "connected": - continue - # .../card1-HDMI-A-1/status -> card="card1", connector="HDMI-A-1" - m = re.match(r"(card\d+)-(HDMI-\S+)", os.path.basename(os.path.dirname(status_path))) - if not m: - continue - card, connector = m.group(1), m.group(2) - outputs.append((f"/dev/dri/{card}", connector)) - return outputs - - -def mirror_socket_path(connector): - return os.path.join(os.path.dirname(PRIMARY_SOCKET), f"mpv-mirror-{connector}.sock") - - -def build_commands(): - """Build the list of mpv argv lists to launch.""" - outputs = connected_hdmi_outputs() - - if not outputs: - # Couldn't identify any connector -- fall back to a single auto-picking - # mpv (the old behavior). Never worse than before. - print("start-mpv: no HDMI connector detected, launching single auto mpv", flush=True) - return [COMMON_ARGS + [f"--input-ipc-server={PRIMARY_SOCKET}", "--ao=alsa"]] - - commands = [] - primary_card, primary_connector = outputs[0] - print(f"start-mpv: primary on {primary_connector} ({primary_card})", flush=True) - commands.append( - COMMON_ARGS - + [ - f"--drm-device={primary_card}", - f"--drm-connector={primary_connector}", - f"--input-ipc-server={PRIMARY_SOCKET}", - "--ao=alsa", - ] - ) - - for card, connector in outputs[1:]: - sock = mirror_socket_path(connector) - print(f"start-mpv: mirror on {connector} ({card}) -> {sock}", flush=True) - commands.append( - COMMON_ARGS - + [ - f"--drm-device={card}", - f"--drm-connector={connector}", - f"--input-ipc-server={sock}", - "--ao=null", - "--mute=yes", - ] - ) - return commands - - -def main(): - # Clean up any stale mirror sockets from a previous run so the app doesn't - # try to talk to a connector that's no longer attached. - for stale in glob.glob(os.path.join(os.path.dirname(PRIMARY_SOCKET), "mpv-mirror-*.sock")): - try: - os.unlink(stale) - except OSError: - pass - - procs = [subprocess.Popen(cmd) for cmd in build_commands()] - - stopping = {"flag": False} - - def kill_children(): - for p in procs: - if p.poll() is None: - p.terminate() - - def on_signal(*_): - # Told to stop (systemctl stop / Ctrl-C): kill the children and let - # main exit 0 so systemd treats it as a clean stop, not a crash. - stopping["flag"] = True - kill_children() - - signal.signal(signal.SIGTERM, on_signal) - signal.signal(signal.SIGINT, on_signal) - - # Poll the children (via subprocess so its bookkeeping stays consistent -- - # don't os.wait() out from under it). The first unexpected exit means a - # screen dropped its mpv: tear the rest down and exit non-zero so systemd - # restarts the unit, re-enumerating displays in the process. time.sleep is - # interrupted by SIGTERM, so a stop is handled promptly too. - while not stopping["flag"]: - dead = next((p for p in procs if p.poll() is not None), None) - if dead is not None: - print(f"start-mpv: an mpv exited unexpectedly (pid {dead.pid}), restarting unit", flush=True) - kill_children() - break - time.sleep(1) - - # Reap whatever's left. - for p in procs: - try: - p.wait(timeout=5) - except subprocess.TimeoutExpired: - p.kill() - - sys.exit(0 if stopping["flag"] else 1) - - -if __name__ == "__main__": - main() diff --git a/systemd/mediapi-mpv.service.template b/systemd/mediapi-mpv.service.template index f9f7354..edaa41f 100644 --- a/systemd/mediapi-mpv.service.template +++ b/systemd/mediapi-mpv.service.template @@ -1,24 +1,28 @@ [Unit] -Description=mpv persistent player (DRM/HDMI output, JSON IPC) -# Wait until udev has settled so the DRM/HDMI devices (/dev/dri/*) exist -# before mpv tries to grab the display. Without this, a cold-boot race can -# leave mpv running but never rendering to HDMI (you see the console instead), -# even though the exact same command works when run by hand later. +Description=mediapi player (X kiosk: one mpv mirrored across all HDMI outputs) +# Wait until udev has settled so the DRM/HDMI devices (/dev/dri/*) exist before +# X tries to grab them. Without this a cold-boot race can leave X unable to find +# a screen. (systemd-udev-settle is deprecated but still the simplest reliable +# gate here; the session script additionally waits for connectors to appear.) After=local-fs.target systemd-udev-settle.service Wants=systemd-udev-settle.service [Service] Type=simple User=${MEDIAPI_USER} -SupplementaryGroups=video render +# video+render: GPU/DRM access. input: evdev under X. tty: own the VT for X. +SupplementaryGroups=video render input tty +# A controlling tty so the X server can take over VT 7. +TTYPath=/dev/tty7 +StandardInput=tty +StandardOutput=journal +StandardError=journal RuntimeDirectory=mediapi RuntimeDirectoryMode=0770 -# start-mpv.py enumerates the connected HDMI connectors and launches one mpv -# per screen: the primary carries audio + the app's IPC socket, and each extra -# screen gets a video-only mirror mpv on its own socket. If any mpv dies the -# launcher exits non-zero and systemd restarts us, which re-enumerates the -# displays. See the script header for the full rationale. -ExecStart=/usr/bin/python3 ${PROJECT_DIR}/scripts/start-mpv.py +# xinit starts the X server on VT 7 and runs the session script as its client. +# The session mirrors the HDMI outputs (single DRM master) and execs one mpv. +# Non-root X is permitted via /etc/X11/Xwrapper.config (installed by install.sh). +ExecStart=/usr/bin/xinit ${PROJECT_DIR}/scripts/mediapi-session.sh -- :0 vt7 -nolisten tcp -keeptty Restart=on-failure RestartSec=2