fully automated install script
This commit is contained in:
parent
db06ae252d
commit
cbda10c62c
10 changed files with 470 additions and 167 deletions
11
.env.example
11
.env.example
|
|
@ -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
6
.gitignore
vendored
|
|
@ -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/
|
||||||
|
|
|
||||||
58
README.md
58
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)
|
## 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
139
deploy.sh
|
|
@ -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
184
install.sh
Executable 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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
171
scripts/start-mpv.py
Executable 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()
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue