diff --git a/.env.example b/.env.example index bcfa263..a05deb4 100644 --- a/.env.example +++ b/.env.example @@ -15,8 +15,17 @@ MEDIAPI_PORT=8080 # --- System: systemd services run as this Linux user --- MEDIAPI_USER=pi -# --- WiFi access point (re-applied by deploy.sh) --- +# --- WiFi access point (re-applied by install.sh) --- MEDIAPI_WIFI_COUNTRY=US MEDIAPI_AP_SSID=changeme MEDIAPI_AP_PASSWORD=changeme MEDIAPI_AP_CONN_NAME=mediapi-ap + +# --- Advanced (leave unset unless you know you need them) --- +# Where the mpv IPC socket + runtime dir live. Defaults below are correct for +# the shipped systemd unit, which hardcodes RuntimeDirectory=mediapi (=/run/mediapi). +# If you override MEDIAPI_RUNTIME_DIR you MUST also edit +# systemd/mediapi-mpv.service.template's RuntimeDirectory to match, or the dir +# won't be created at boot. Most deployments should leave both commented out. +#MEDIAPI_RUNTIME_DIR=/run/mediapi +#MEDIAPI_MPV_SOCKET=/run/mediapi/mpv.sock diff --git a/.gitignore b/.gitignore index 369da16..fe4f10a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,10 @@ __pycache__/ # Real per-deployment config / secrets .env -# Flask session signing key -- generated per-deployment, must stay secret -instance/secret_key +# Flask instance dir -- per-deployment state generated on the Pi: +# secret_key session signing key (must stay secret) +# deployed_ref commit of the last healthy deploy (install.sh rollback target) +instance/ # Local Claude Code settings .claude/ diff --git a/README.md b/README.md index b24e557..89670fd 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ git remote set-url --add --push origin git@github.com:jmtremblay2/mediapi.git ## Setup (Raspberry Pi OS Bookworm / NetworkManager) -`deploy.sh` creates/updates the AP connection for you from the `.env` values +`install.sh` creates/updates the AP connection for you from the `.env` values (see [Configuration](#configuration-env) below), so normally you don't run these by hand. For reference, this is what it does — substitute the `MEDIAPI_*` values from your `.env`: @@ -104,7 +104,7 @@ cp .env.example .env `.env` is read two ways, so it stays the single source of truth: * the Flask app parses it at startup (`mediapi/config.py`, no extra dependency), -* `deploy.sh` sources it to configure the AP + render the systemd units. +* `install.sh` sources it to configure the AP + render the systemd units. Keep values simple (no spaces / shell-special characters). @@ -115,6 +115,18 @@ 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. +**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). + ### First-time install (on the Pi) ```bash @@ -133,8 +145,9 @@ curl -LsSf https://astral.sh/uv/install.sh | sh # installs to ~/.local/bin/uv # create your .env (see Configuration above) cp .env.example .env && $EDITOR .env -# deploy: syncs deps, configures the AP, installs+starts the services -./deploy.sh +# deploy the currently-checked-out ref: syncs deps, configures the AP, +# installs+starts the services +./install.sh # verify systemctl status mediapi-mpv mediapi-app @@ -148,22 +161,37 @@ confirm with `ip addr show wlan0` on the pi) and log in with the ### Deploying updates -`deploy.sh` pulls the latest commit from `origin`, syncs deps, re-applies the -AP config, reinstalls the systemd units (rendered from the `.template` files -using your `.env`), restarts the services, and health-checks the app — -**rolling back to the previous commit automatically if it fails to come up.** +`install.sh` deploys **whatever git ref is currently checked out** — it does +*not* pull on its own, so you control exactly which version goes live (pin a +tag for a reproducible car deployment). It syncs deps, re-applies the AP config, +reinstalls the systemd units (rendered from the `.template` files using your +`.env`), restarts the services, and health-checks the app. The same script is +used for the first install and every update; each run is idempotent and cleans +up the previous deployment in place. + +After a healthy deploy it records the deployed commit in `instance/deployed_ref`. +If a new deploy fails its health check, it **rolls back to that last-known-good +ref automatically** and re-checks. ```bash -./deploy.sh +git fetch --tags +git checkout v1.2.0 # pin the version you want +./install.sh + +# or let the script do the checkout for you: +./install.sh v1.2.0 ``` +Passing a ref checks it out with `git checkout --detach` before deploying; with +no argument it deploys the current checkout as-is (branch or tag). + It **refuses to run while the read-only overlay is active** (changes would vanish on reboot) and prints the disable/re-enable steps. So the update loop is: ```bash sudo raspi-config nonint do_overlayfs 1 && sudo reboot # disable overlay # ... after reboot: -cd ~/mediapi && ./deploy.sh +cd ~/mediapi && ./install.sh v1.2.0 sudo raspi-config nonint do_overlayfs 0 && sudo reboot # re-enable overlay ``` @@ -171,9 +199,13 @@ 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. -* If HDMI isn't picked automatically, `mpv --drm-connector=help` (with a - display attached) lists connectors to pin one explicitly via the - `ExecStart` line in `systemd/mediapi-mpv.service.template`. +* 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. * 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/deploy.sh b/deploy.sh deleted file mode 100755 index a0872fa..0000000 --- a/deploy.sh +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env bash -# -# deploy.sh -- pull the latest mediapi and (re)deploy it on the Raspberry Pi. -# -# Runs ON the Pi. It: -# 0. refuses to run if the root filesystem is a read-only overlay -# 1. pulls the latest commit from origin (forgejo) with --ff-only -# 2. syncs Python deps with uv -# 3. ensures the session secret key exists (while the card is writable) -# 4. re-applies the WiFi country + AP connection from .env -# 5. renders + installs the systemd unit templates -# 6. restarts the services -# 7. health-checks the app, and rolls back to the previous commit if it -# fails to come up -# -# Config comes from .env (copy .env.example -> .env first). Needs sudo for -# nmcli / systemctl / writing to /etc; you'll be prompted as needed. -# -set -euo pipefail - -PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$PROJECT_DIR" - -# --- load config ---------------------------------------------------- -if [[ ! -f .env ]]; then - echo "ERROR: .env not found. Copy .env.example to .env and fill it in." >&2 - exit 1 -fi -set -a -# shellcheck disable=SC1091 -source .env -set +a - -MEDIAPI_USER="${MEDIAPI_USER:-$(id -un)}" -MEDIAPI_PORT="${MEDIAPI_PORT:-8080}" -UV="$(command -v uv || echo "$HOME/.local/bin/uv")" - -# --- 0. refuse to run on a read-only overlay ------------------------ -if findmnt -no FSTYPE / | grep -q overlay; then - cat >&2 < Pulling latest from origin/$BRANCH ..." -if ! git pull --ff-only origin "$BRANCH"; then - echo "ERROR: git pull failed (uncommitted changes or non-fast-forward)." >&2 - exit 1 -fi -NEW_REF="$(git rev-parse HEAD)" -echo " $PREV_REF -> $NEW_REF" - -# --- render + install systemd units (used again on rollback) -------- -install_units() { - for unit in mediapi-mpv mediapi-app; do - sed -e "s|\${MEDIAPI_USER}|${MEDIAPI_USER}|g" \ - -e "s|\${PROJECT_DIR}|${PROJECT_DIR}|g" \ - -e "s|\${UV}|${UV}|g" \ - "systemd/${unit}.service.template" \ - | sudo tee "/etc/systemd/system/${unit}.service" >/dev/null - done - sudo systemctl daemon-reload -} - -# --- 2. sync python deps ------------------------------------------- -echo "==> Syncing dependencies (uv sync) ..." -"$UV" sync - -# --- 3. ensure session secret key exists --------------------------- -if [[ ! -f instance/secret_key ]]; then - echo "==> Generating instance/secret_key ..." - mkdir -p instance - python3 -c "import os; print(os.urandom(32).hex())" > instance/secret_key - chmod 600 instance/secret_key -fi - -# --- 4. wifi country + AP connection ------------------------------- -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 - echo " updating existing AP connection '${MEDIAPI_AP_CONN_NAME}'" - sudo nmcli con modify "${MEDIAPI_AP_CONN_NAME}" \ - 802-11-wireless.ssid "${MEDIAPI_AP_SSID}" \ - wifi-sec.key-mgmt wpa-psk \ - wifi-sec.psk "${MEDIAPI_AP_PASSWORD}" -else - echo " creating AP connection '${MEDIAPI_AP_CONN_NAME}'" - sudo nmcli con add type wifi ifname wlan0 con-name "${MEDIAPI_AP_CONN_NAME}" \ - autoconnect yes connection.autoconnect-priority 100 save yes \ - 802-11-wireless.mode ap 802-11-wireless.band bg \ - 802-11-wireless.ssid "${MEDIAPI_AP_SSID}" \ - wifi-sec.key-mgmt wpa-psk wifi-sec.psk "${MEDIAPI_AP_PASSWORD}" \ - ipv4.method shared -fi - -# --- 5 & 6. install units + restart -------------------------------- -echo "==> Installing systemd units + restarting services ..." -install_units -sudo systemctl enable mediapi-mpv mediapi-app >/dev/null 2>&1 || true -sudo systemctl restart mediapi-mpv -sudo systemctl restart mediapi-app - -# --- 7. health check + rollback ------------------------------------ -echo "==> Health check on http://127.0.0.1:${MEDIAPI_PORT}/login ..." -healthy=0 -for _ in $(seq 1 15); do - if curl -fsS -o /dev/null "http://127.0.0.1:${MEDIAPI_PORT}/login"; then - healthy=1 - break - fi - sleep 1 -done - -if [[ "$healthy" -ne 1 ]]; then - echo "ERROR: app did not become healthy -- rolling back to $PREV_REF" >&2 - git reset --hard "$PREV_REF" - "$UV" sync - install_units - sudo systemctl restart mediapi-mpv mediapi-app - echo "--- last 30 lines of mediapi-app log: ---" >&2 - sudo journalctl -u mediapi-app -n 30 --no-pager >&2 || true - exit 1 -fi - -echo "==> Deploy OK. Services healthy on port ${MEDIAPI_PORT}." -systemctl --no-pager --lines=0 status mediapi-mpv mediapi-app || true diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8495152 --- /dev/null +++ b/install.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# +# install.sh -- (re)deploy mediapi on the Raspberry Pi from the CURRENT checkout. +# +# Runs ON the Pi. Unlike a pull-based deployer, this script deploys whatever +# git ref is currently checked out -- so the intended workflow is: +# +# git fetch --tags +# git checkout v1.2.0 # (or any tag/branch/commit) +# ./install.sh +# +# or, as a one-liner that does the checkout for you: +# +# ./install.sh v1.2.0 +# +# It is fully re-runnable (idempotent): each run re-renders the systemd units, +# upserts the WiFi AP connection, syncs deps, and restarts the services, +# cleaning up the previous deployment in place. After a healthy deploy it +# records the deployed commit in instance/deployed_ref; if a new deploy fails +# its health check, it rolls back to that last-known-good ref automatically. +# +# What it does, in order: +# 0. refuses to run if the root filesystem is a read-only overlay +# 1. (optional) checks out the ref passed as $1 +# 2. syncs Python deps with uv +# 3. ensures the session secret key exists (while the card is writable) +# 4. re-applies the WiFi country + AP connection from .env +# 5. renders + installs the systemd unit templates +# 6. restarts the services +# 7. health-checks the app, rolling back to the last-good ref if it fails +# +# Config comes from .env (copy .env.example -> .env first). Needs sudo for +# nmcli / systemctl / writing to /etc; you'll be prompted as needed. +# +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$PROJECT_DIR" + +DEPLOYED_REF_FILE="instance/deployed_ref" +TARGET_REF="${1:-}" + +# --- load config ---------------------------------------------------- +if [[ ! -f .env ]]; then + echo "ERROR: .env not found. Copy .env.example to .env and fill it in." >&2 + exit 1 +fi +set -a +# shellcheck disable=SC1091 +source .env +set +a + +MEDIAPI_USER="${MEDIAPI_USER:-$(id -un)}" +MEDIAPI_PORT="${MEDIAPI_PORT:-8080}" +UV="$(command -v uv || echo "$HOME/.local/bin/uv")" + +# --- 0. refuse to run on a read-only overlay ------------------------ +if findmnt -no FSTYPE / | grep -q overlay; then + cat >&2 < Checking out '$TARGET_REF' ..." + git checkout --detach "$TARGET_REF" +fi + +CURRENT_REF="$(git rev-parse HEAD)" +CURRENT_DESC="$(git describe --tags --always 2>/dev/null || echo "$CURRENT_REF")" +echo "==> Deploying $CURRENT_DESC ($CURRENT_REF)" +if [[ -n "$PREV_GOOD" && "$PREV_GOOD" != "$CURRENT_REF" ]]; then + echo " (last healthy deploy was $PREV_GOOD -- rollback target if this fails)" +fi + +# --- render + install systemd units (used again on rollback) -------- +install_units() { + for unit in mediapi-mpv mediapi-app; do + sed -e "s|\${MEDIAPI_USER}|${MEDIAPI_USER}|g" \ + -e "s|\${PROJECT_DIR}|${PROJECT_DIR}|g" \ + -e "s|\${UV}|${UV}|g" \ + "systemd/${unit}.service.template" \ + | sudo tee "/etc/systemd/system/${unit}.service" >/dev/null + done + sudo systemctl daemon-reload +} + +# --- deploy the currently-checked-out tree -------------------------- +# Factored out so both the initial deploy and a rollback run the exact same +# steps against whatever ref is checked out at the time. +deploy_current() { + echo "==> Syncing dependencies (uv sync) ..." + "$UV" sync + + if [[ ! -f instance/secret_key ]]; then + echo "==> Generating instance/secret_key ..." + mkdir -p instance + python3 -c "import os; print(os.urandom(32).hex())" > instance/secret_key + chmod 600 instance/secret_key + fi + + 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 + echo " updating existing AP connection '${MEDIAPI_AP_CONN_NAME}'" + sudo nmcli con modify "${MEDIAPI_AP_CONN_NAME}" \ + 802-11-wireless.ssid "${MEDIAPI_AP_SSID}" \ + wifi-sec.key-mgmt wpa-psk \ + wifi-sec.psk "${MEDIAPI_AP_PASSWORD}" + else + echo " creating AP connection '${MEDIAPI_AP_CONN_NAME}'" + sudo nmcli con add type wifi ifname wlan0 con-name "${MEDIAPI_AP_CONN_NAME}" \ + autoconnect yes connection.autoconnect-priority 100 save yes \ + 802-11-wireless.mode ap 802-11-wireless.band bg \ + 802-11-wireless.ssid "${MEDIAPI_AP_SSID}" \ + wifi-sec.key-mgmt wpa-psk wifi-sec.psk "${MEDIAPI_AP_PASSWORD}" \ + ipv4.method shared + fi + + echo "==> Installing systemd units + restarting services ..." + install_units + sudo systemctl enable mediapi-mpv mediapi-app >/dev/null 2>&1 || true + sudo systemctl restart mediapi-mpv + sudo systemctl restart mediapi-app +} + +# --- health check --------------------------------------------------- +app_healthy() { + for _ in $(seq 1 15); do + if curl -fsS -o /dev/null "http://127.0.0.1:${MEDIAPI_PORT}/login"; then + return 0 + fi + sleep 1 + done + return 1 +} + +# --- 2-6. deploy ---------------------------------------------------- +deploy_current + +# --- 7. health check + rollback ------------------------------------ +echo "==> Health check on http://127.0.0.1:${MEDIAPI_PORT}/login ..." +if app_healthy; then + echo "$CURRENT_REF" > "$DEPLOYED_REF_FILE" + echo "==> Deploy OK. $CURRENT_DESC healthy on port ${MEDIAPI_PORT}." + systemctl --no-pager --lines=0 status mediapi-mpv mediapi-app || true + exit 0 +fi + +echo "ERROR: app did not become healthy after deploying $CURRENT_DESC." >&2 +echo "--- last 30 lines of mediapi-app log: ---" >&2 +sudo journalctl -u mediapi-app -n 30 --no-pager >&2 || true + +if [[ -z "$PREV_GOOD" || "$PREV_GOOD" == "$CURRENT_REF" ]]; then + echo "ERROR: no previous healthy deploy to roll back to. Left as-is." >&2 + exit 1 +fi + +echo "==> Rolling back to last healthy deploy $PREV_GOOD ..." >&2 +git checkout --detach "$PREV_GOOD" +deploy_current +if app_healthy; then + echo "==> Rolled back to $PREV_GOOD; it is healthy on port ${MEDIAPI_PORT}." >&2 +else + echo "ERROR: rollback to $PREV_GOOD ALSO failed its health check. Manual fix needed." >&2 +fi +exit 1 diff --git a/mediapi/__init__.py b/mediapi/__init__.py index 4d1d4de..c742a8f 100644 --- a/mediapi/__init__.py +++ b/mediapi/__init__.py @@ -18,7 +18,11 @@ def create_app(): register_auth_gate(app) - app.player = PlayerStateManager(app.config["MPV_SOCKET"], app.config["VIDEO_EXTENSIONS"]) + app.player = PlayerStateManager( + app.config["MPV_SOCKET"], + app.config["VIDEO_EXTENSIONS"], + mirror_glob=app.config.get("MPV_MIRROR_GLOB"), + ) app.player.start() return app diff --git a/mediapi/config.py b/mediapi/config.py index dc58620..7e1ca26 100644 --- a/mediapi/config.py +++ b/mediapi/config.py @@ -42,7 +42,7 @@ def load_secret_key(): On a read-only overlay filesystem the write will fail; in that case we fall back to an ephemeral in-memory key so the app still starts (sessions - just won't survive a restart). deploy.sh generates this file while the + just won't survive a restart). install.sh generates this file while the card is writable, so in normal operation the write path isn't hit.""" if os.path.exists(SECRET_KEY_PATH): with open(SECRET_KEY_PATH) as f: @@ -76,6 +76,10 @@ class Config: p for p in os.environ.get("MEDIAPI_MEDIA_ROOTS", "/localmedia").split(":") if p ] 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 730bdb4..ed8596e 100644 --- a/mediapi/player.py +++ b/mediapi/player.py @@ -1,3 +1,4 @@ +import glob import logging import os import threading @@ -18,9 +19,16 @@ 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): + def __init__(self, socket_path, video_extensions, mirror_glob=None): 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() self._client = MpvIPCClient(socket_path) self._lock = threading.Lock() @@ -129,6 +137,7 @@ 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 --------------------------------------------------- @@ -138,10 +147,37 @@ 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. @@ -163,6 +199,7 @@ 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 @@ -170,9 +207,11 @@ 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/start-mpv.py b/scripts/start-mpv.py new file mode 100755 index 0000000..c7481e7 --- /dev/null +++ b/scripts/start-mpv.py @@ -0,0 +1,171 @@ +#!/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 9aa2c4a..f9f7354 100644 --- a/systemd/mediapi-mpv.service.template +++ b/systemd/mediapi-mpv.service.template @@ -13,15 +13,12 @@ User=${MEDIAPI_USER} SupplementaryGroups=video render RuntimeDirectory=mediapi RuntimeDirectoryMode=0770 -ExecStart=/usr/bin/mpv \ - --idle=yes \ - --input-ipc-server=/run/mediapi/mpv.sock \ - --vo=gpu-next --gpu-context=drm \ - --hwdec=auto-safe \ - --fullscreen \ - --no-terminal \ - --keep-open=no \ - --ao=alsa +# 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 Restart=on-failure RestartSec=2