fully automated install script

This commit is contained in:
Jean-Michel Tremblay 2026-07-06 20:33:46 -04:00
parent db06ae252d
commit cbda10c62c
10 changed files with 470 additions and 167 deletions

View file

@ -15,8 +15,17 @@ MEDIAPI_PORT=8080
# --- System: systemd services run as this Linux user --- # --- System: systemd services run as this Linux user ---
MEDIAPI_USER=pi MEDIAPI_USER=pi
# --- WiFi access point (re-applied by deploy.sh) --- # --- WiFi access point (re-applied by install.sh) ---
MEDIAPI_WIFI_COUNTRY=US MEDIAPI_WIFI_COUNTRY=US
MEDIAPI_AP_SSID=changeme MEDIAPI_AP_SSID=changeme
MEDIAPI_AP_PASSWORD=changeme MEDIAPI_AP_PASSWORD=changeme
MEDIAPI_AP_CONN_NAME=mediapi-ap 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

6
.gitignore vendored
View file

@ -8,8 +8,10 @@ __pycache__/
# Real per-deployment config / secrets # Real per-deployment config / secrets
.env .env
# Flask session signing key -- generated per-deployment, must stay secret # Flask instance dir -- per-deployment state generated on the Pi:
instance/secret_key # 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 # Local Claude Code settings
.claude/ .claude/

View file

@ -31,7 +31,7 @@ git remote set-url --add --push origin git@github.com:jmtremblay2/mediapi.git
## Setup (Raspberry Pi OS Bookworm / NetworkManager) ## 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 (see [Configuration](#configuration-env) below), so normally you don't run
these by hand. For reference, this is what it does — substitute the these by hand. For reference, this is what it does — substitute the
`MEDIAPI_*` values from your `.env`: `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: `.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), * 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). 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 directly (DRM/KMS, no desktop needed); the phone browser only shows
metadata/controls, never the video image itself. 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-<connector>.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) ### First-time install (on the Pi)
```bash ```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) # create your .env (see Configuration above)
cp .env.example .env && $EDITOR .env cp .env.example .env && $EDITOR .env
# deploy: syncs deps, configures the AP, installs+starts the services # deploy the currently-checked-out ref: syncs deps, configures the AP,
./deploy.sh # installs+starts the services
./install.sh
# verify # verify
systemctl status mediapi-mpv mediapi-app 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 ### Deploying updates
`deploy.sh` pulls the latest commit from `origin`, syncs deps, re-applies the `install.sh` deploys **whatever git ref is currently checked out** — it does
AP config, reinstalls the systemd units (rendered from the `.template` files *not* pull on its own, so you control exactly which version goes live (pin a
using your `.env`), restarts the services, and health-checks the app — tag for a reproducible car deployment). It syncs deps, re-applies the AP config,
**rolling back to the previous commit automatically if it fails to come up.** 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 ```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 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: vanish on reboot) and prints the disable/re-enable steps. So the update loop is:
```bash ```bash
sudo raspi-config nonint do_overlayfs 1 && sudo reboot # disable overlay sudo raspi-config nonint do_overlayfs 1 && sudo reboot # disable overlay
# ... after reboot: # ... 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 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): from a dev machine):
* `dtoverlay=vc4-kms-v3d` should already be set in `/boot/firmware/config.txt` * `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. 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 * Mirroring picks up whatever HDMI connectors read `connected` under
display attached) lists connectors to pin one explicitly via the `/sys/class/drm/card*-HDMI-*/status` at service start — so plug in both
`ExecStart` line in `systemd/mediapi-mpv.service.template`. 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 * 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. device name (usually `vc4-hdmi`) and add e.g.
`--audio-device=alsa/plughw:CARD=vc4hdmi0,DEV=0` to the mpv unit template, or `--audio-device=alsa/plughw:CARD=vc4hdmi0,DEV=0` to the mpv unit template, or

139
deploy.sh
View file

@ -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 <<EOF
ERROR: the root filesystem is a read-only overlay -- any changes would vanish
on the next reboot. Disable the overlay, reboot, re-run this script, then
re-enable it:
sudo raspi-config nonint do_overlayfs 1 # disable overlay
sudo reboot
# ... after reboot:
cd $PROJECT_DIR && ./deploy.sh
sudo raspi-config nonint do_overlayfs 0 # re-enable overlay
sudo reboot
EOF
exit 1
fi
# --- 1. pull latest -------------------------------------------------
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
PREV_REF="$(git rev-parse HEAD)"
echo "==> 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

184
install.sh Executable file
View file

@ -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 <<EOF
ERROR: the root filesystem is a read-only overlay -- any changes would vanish
on the next reboot. Disable the overlay, reboot, re-run this script, then
re-enable it:
sudo raspi-config nonint do_overlayfs 1 # disable overlay
sudo reboot
# ... after reboot:
cd $PROJECT_DIR && ./install.sh${TARGET_REF:+ $TARGET_REF}
sudo raspi-config nonint do_overlayfs 0 # re-enable overlay
sudo reboot
EOF
exit 1
fi
# --- 1. (optional) check out the requested ref ----------------------
# The last healthy deploy's commit, if any -- our rollback target.
PREV_GOOD=""
if [[ -f "$DEPLOYED_REF_FILE" ]]; then
PREV_GOOD="$(cat "$DEPLOYED_REF_FILE")"
fi
if [[ -n "$TARGET_REF" ]]; then
echo "==> 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

View file

@ -18,7 +18,11 @@ def create_app():
register_auth_gate(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() app.player.start()
return app return app

View file

@ -42,7 +42,7 @@ def load_secret_key():
On a read-only overlay filesystem the write will fail; in that case we 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 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.""" card is writable, so in normal operation the write path isn't hit."""
if os.path.exists(SECRET_KEY_PATH): if os.path.exists(SECRET_KEY_PATH):
with open(SECRET_KEY_PATH) as f: 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 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") 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-<connector>.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")) PORT = int(os.environ.get("MEDIAPI_PORT", "8080"))
VIDEO_EXTENSIONS = { VIDEO_EXTENSIONS = {

View file

@ -1,3 +1,4 @@
import glob
import logging import logging
import os import os
import threading import threading
@ -18,9 +19,16 @@ class PlayerStateManager:
Flask request handlers only ever read the cached snapshot or send a Flask request handlers only ever read the cached snapshot or send a
command through this class -- they never touch the socket directly.""" 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.socket_path = socket_path
self.video_extensions = video_extensions 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._client = MpvIPCClient(socket_path)
self._lock = threading.Lock() self._lock = threading.Lock()
@ -129,6 +137,7 @@ class PlayerStateManager:
self._client.command("loadfile", next_file, "replace") self._client.command("loadfile", next_file, "replace")
except MpvIPCError as exc: except MpvIPCError as exc:
log.warning("auto-advance loadfile failed: %s", exc) log.warning("auto-advance loadfile failed: %s", exc)
self._broadcast("loadfile", next_file, "replace")
# -- public read API --------------------------------------------------- # -- public read API ---------------------------------------------------
@ -138,10 +147,37 @@ class PlayerStateManager:
state["keep_playing"] = self._state["keep_playing"] state["keep_playing"] = self._state["keep_playing"]
return state 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) --------------------- # -- public command API (called from Flask routes) ---------------------
def play_file(self, path): def play_file(self, path):
self._client.command("loadfile", path, "replace") self._client.command("loadfile", path, "replace")
self._broadcast("loadfile", path, "replace")
# Build the auto-advance queue from the containing folder, positioned # Build the auto-advance queue from the containing folder, positioned
# at the file just started -- so "keep playing" continues through the # at the file just started -- so "keep playing" continues through the
# rest of the folder even when you start from the middle. # rest of the folder even when you start from the middle.
@ -163,6 +199,7 @@ class PlayerStateManager:
if not files: if not files:
raise ValueError(f"no video files in {folder_path}") raise ValueError(f"no video files in {folder_path}")
self._client.command("loadfile", files[0], "replace") self._client.command("loadfile", files[0], "replace")
self._broadcast("loadfile", files[0], "replace")
with self._lock: with self._lock:
self._queue_folder = folder_path self._queue_folder = folder_path
self._queue_files = files self._queue_files = files
@ -170,9 +207,11 @@ class PlayerStateManager:
def playpause(self): def playpause(self):
self._client.command("cycle", "pause") self._client.command("cycle", "pause")
self._broadcast("cycle", "pause")
def seek(self, offset_seconds): def seek(self, offset_seconds):
self._client.command("seek", offset_seconds, "relative") self._client.command("seek", offset_seconds, "relative")
self._broadcast("seek", offset_seconds, "relative")
def set_volume(self, value): def set_volume(self, value):
value = max(0, min(100, value)) value = max(0, min(100, value))

171
scripts/start-mpv.py Executable file
View file

@ -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-<connector>.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()

View file

@ -13,15 +13,12 @@ User=${MEDIAPI_USER}
SupplementaryGroups=video render SupplementaryGroups=video render
RuntimeDirectory=mediapi RuntimeDirectory=mediapi
RuntimeDirectoryMode=0770 RuntimeDirectoryMode=0770
ExecStart=/usr/bin/mpv \ # start-mpv.py enumerates the connected HDMI connectors and launches one mpv
--idle=yes \ # per screen: the primary carries audio + the app's IPC socket, and each extra
--input-ipc-server=/run/mediapi/mpv.sock \ # screen gets a video-only mirror mpv on its own socket. If any mpv dies the
--vo=gpu-next --gpu-context=drm \ # launcher exits non-zero and systemd restarts us, which re-enumerates the
--hwdec=auto-safe \ # displays. See the script header for the full rationale.
--fullscreen \ ExecStart=/usr/bin/python3 ${PROJECT_DIR}/scripts/start-mpv.py
--no-terminal \
--keep-open=no \
--ao=alsa
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2