Extract config into .env; add deploy.sh with overlay guard + rollback

- Move user-specific config (login, media roots, port, AP ssid/psk, service
  user) into a gitignored .env; add .env.example template.
- config.py reads .env at startup via a tiny zero-dependency parser (works for
  both `uv run` and systemd), and fails loudly if required vars are unset.
- load_secret_key falls back to an ephemeral key on a read-only filesystem
  instead of crashing.
- systemd units become .template files; deploy.sh renders them with the
  .env-derived user/paths.
- deploy.sh: refuses to run on a read-only overlay, git pull --ff-only,
  uv sync, ensure secret key, re-apply the AP from .env, install units,
  restart, health-check with automatic rollback to the previous commit.
- Scrub literal credentials/ssid out of the README; document .env + deploy.sh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Michel Tremblay 2026-07-05 22:06:47 -04:00
parent 3a50ba1e3f
commit ed9da9a065
8 changed files with 297 additions and 40 deletions

22
.env.example Normal file
View file

@ -0,0 +1,22 @@
# Copy this file to .env and fill in real values.
# .env is gitignored -- never commit real secrets.
# Values must be simple (no spaces / shell-special chars) -- this file is both
# parsed by the app and sourced by deploy.sh.
# --- App login (custom login form) ---
MEDIAPI_USERNAME=changeme
MEDIAPI_PASSWORD=changeme
# --- Media ---
# colon-separated list of directories to browse
MEDIAPI_MEDIA_ROOTS=/localmedia
MEDIAPI_PORT=8080
# --- System: systemd services run as this Linux user ---
MEDIAPI_USER=pi
# --- WiFi access point (re-applied by deploy.sh) ---
MEDIAPI_WIFI_COUNTRY=US
MEDIAPI_AP_SSID=changeme
MEDIAPI_AP_PASSWORD=changeme
MEDIAPI_AP_CONN_NAME=mediapi-ap

3
.gitignore vendored
View file

@ -5,6 +5,9 @@ __pycache__/
# uv / virtualenv
.venv/
# Real per-deployment config / secrets
.env
# Flask session signing key -- generated per-deployment, must stay secret
instance/secret_key

View file

@ -25,35 +25,40 @@ git remote set-url --add --push origin git@github.com:jmtremblay2/mediapi.git
# AP on the raspberry pi
* must have access point that boots up on system boot
* ssid: mediapipi, pw: mediapipi
* ssid + password come from `.env` (`MEDIAPI_AP_SSID` / `MEDIAPI_AP_PASSWORD`)
* fine if it's slow, fine if it does not boot right away (wait for other services)
* (I will connect to the AP from my phone and control what the pi plays)
## Setup (Raspberry Pi OS Bookworm / NetworkManager)
`deploy.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`:
```bash
# set the wifi regulatory domain (required, affects allowed channels/power)
sudo raspi-config nonint do_wifi_country US
sudo raspi-config nonint do_wifi_country "$MEDIAPI_WIFI_COUNTRY"
# create the AP connection profile on wlan0
# ipv4.method=shared makes NetworkManager run its own DHCP server (dnsmasq)
# on wlan0 for the phone to get an address from -- no internet sharing/NAT
# involved since there's no other upstream connection active
sudo nmcli connection add type wifi ifname wlan0 con-name mediapi-ap \
sudo nmcli connection 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 mediapipi \
wifi-sec.key-mgmt wpa-psk wifi-sec.psk mediapipi \
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
# bring it up now (it will also auto-start on every boot from here on)
sudo nmcli connection up mediapi-ap
sudo nmcli connection up "$MEDIAPI_AP_CONN_NAME"
# verify
nmcli connection show mediapi-ap
nmcli connection show "$MEDIAPI_AP_CONN_NAME"
ip addr show wlan0
```
To remove it later: `sudo nmcli connection delete mediapi-ap`
To remove it later: `sudo nmcli connection delete "$MEDIAPI_AP_CONN_NAME"`
# app to browse media
media will be stored at
@ -81,12 +86,28 @@ the app should have two buttons up top that show the two modes all the time. the
* plays on both HDMI mirrored if connected.
## basic auth
user: jujualexevan
pw: bambas
fine to keep known devices logged in forever pretty much
* login username + password come from `.env` (`MEDIAPI_USERNAME` / `MEDIAPI_PASSWORD`)
* fine to keep known devices logged in forever pretty much
easy to run ... I don't want to have to download a gazillion things
## Configuration (.env)
All per-deployment config (login, media paths, port, the AP ssid/password,
the Linux user the services run as) lives in a single `.env` file at the repo
root. It is **gitignored** — never commit it. Copy the template and fill it in:
```bash
cp .env.example .env
# then edit .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.
Keep values simple (no spaces / shell-special characters).
## Setup (mediapi app)
Implemented as a small Flask app (`mediapi/`) + a permanently-running `mpv`
@ -94,37 +115,57 @@ 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.
### First-time install (on the Pi)
```bash
# system deps: mpv for HDMI/DRM playback
sudo apt update
sudo apt install -y mpv
# mpv needs access to the GPU/DRM devices to render to HDMI
sudo usermod -aG video,render jm
# (use the MEDIAPI_USER from your .env)
sudo usermod -aG video,render "$USER"
# log out/in (or reboot) for the new group membership to take effect
# install uv (Python package/venv manager) if not already present
curl -LsSf https://astral.sh/uv/install.sh | sh
# installs to ~/.local/bin/uv -- confirm with `which uv`; if it differs,
# update the ExecStart path in systemd/mediapi-app.service to match
curl -LsSf https://astral.sh/uv/install.sh | sh # installs to ~/.local/bin/uv
cd /home/jm/mediapi
uv sync # creates .venv and installs dependencies from pyproject.toml
# create your .env (see Configuration above)
cp .env.example .env && $EDITOR .env
# install the two systemd services (mpv player + the web app)
sudo cp systemd/mediapi-mpv.service systemd/mediapi-app.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now mediapi-mpv mediapi-app
# deploy: syncs deps, configures the AP, installs+starts the services
./deploy.sh
# verify
systemctl status mediapi-mpv mediapi-app
ls -l /run/mediapi/mpv.sock
```
Then, from a phone connected to the `mediapipi` AP, browse to
Then, from a phone connected to the AP (`MEDIAPI_AP_SSID`), browse to
`http://<pi-ap-ip>:8080/` (the AP's gateway address, typically `10.42.0.1`
confirm with `ip addr show wlan0` on the pi) and log in with the
`jujualexevan` / `bambas` credentials above.
`MEDIAPI_USERNAME` / `MEDIAPI_PASSWORD` from your `.env`.
### 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.**
```bash
./deploy.sh
```
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
sudo raspi-config nonint do_overlayfs 0 && sudo reboot # re-enable overlay
```
Notes / things to double check on the actual hardware (couldn't be verified
from a dev machine):
@ -132,10 +173,10 @@ from a dev machine):
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`.
`ExecStart` line in `systemd/mediapi-mpv.service.template`.
* 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, or run
`sudo raspi-config nonint do_audio 2` to force HDMI as the default output.
`--audio-device=alsa/plughw:CARD=vc4hdmi0,DEV=0` to the mpv unit template, or
run `sudo raspi-config nonint do_audio 2` to force HDMI as the default output.
To stop/remove: `sudo systemctl disable --now mediapi-mpv mediapi-app`

139
deploy.sh Executable file
View file

@ -0,0 +1,139 @@
#!/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

View file

@ -1,18 +1,67 @@
import logging
import os
from datetime import timedelta
log = logging.getLogger(__name__)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
INSTANCE_DIR = os.path.join(BASE_DIR, "instance")
SECRET_KEY_PATH = os.path.join(INSTANCE_DIR, "secret_key")
DOTENV_PATH = os.path.join(BASE_DIR, ".env")
def load_dotenv(path=DOTENV_PATH):
"""Minimal .env loader (no dependency). Populates os.environ for any key
not already set, so real environment variables always win over the file.
Works for both `uv run run.py` and the systemd-launched service."""
if not os.path.exists(path):
return
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key = key.strip()
val = val.strip().strip('"').strip("'")
if key:
os.environ.setdefault(key, val)
def require(name):
val = os.environ.get(name)
if not val:
raise RuntimeError(
f"{name} is not set -- copy .env.example to .env and fill it in"
)
return val
def load_secret_key():
os.makedirs(INSTANCE_DIR, exist_ok=True)
if not os.path.exists(SECRET_KEY_PATH):
"""Read the persisted session-signing key, generating it if missing.
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
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:
return f.read().strip()
key = os.urandom(32).hex()
try:
os.makedirs(INSTANCE_DIR, exist_ok=True)
with open(SECRET_KEY_PATH, "w") as f:
f.write(os.urandom(32).hex())
with open(SECRET_KEY_PATH) as f:
return f.read().strip()
f.write(key)
except OSError:
log.warning(
"could not persist secret key (read-only filesystem?) -- using an "
"ephemeral key; logins won't survive an app restart"
)
return key
load_dotenv()
class Config:
@ -20,11 +69,14 @@ class Config:
PERMANENT_SESSION_LIFETIME = timedelta(days=3650)
SESSION_COOKIE_SAMESITE = "Lax"
USERNAME = "jujualexevan"
PASSWORD = "bambas"
USERNAME = require("MEDIAPI_USERNAME")
PASSWORD = require("MEDIAPI_PASSWORD")
MEDIA_ROOTS = ["/localmedia"]
MPV_SOCKET = "/run/mediapi/mpv.sock"
MEDIA_ROOTS = [
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")
PORT = int(os.environ.get("MEDIAPI_PORT", "8080"))
VIDEO_EXTENSIONS = {
".mp4", ".mkv", ".avi", ".mov", ".m4v", ".webm", ".mpg", ".mpeg", ".ts", ".flv", ".wmv",

2
run.py
View file

@ -3,4 +3,4 @@ from mediapi import create_app
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080, threaded=True)
app.run(host="0.0.0.0", port=app.config["PORT"], threaded=True)

View file

@ -5,9 +5,9 @@ Wants=network-online.target mediapi-mpv.service
[Service]
Type=simple
User=jm
WorkingDirectory=/home/jm/mediapi
ExecStart=/home/jm/.local/bin/uv run run.py
User=${MEDIAPI_USER}
WorkingDirectory=${PROJECT_DIR}
ExecStart=${UV} run run.py
Restart=on-failure
RestartSec=2

View file

@ -4,7 +4,7 @@ After=local-fs.target
[Service]
Type=simple
User=jm
User=${MEDIAPI_USER}
SupplementaryGroups=video render
RuntimeDirectory=mediapi
RuntimeDirectoryMode=0770