diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bcfa263 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 45316f7..369da16 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 6d46609..b24e557 100644 --- a/README.md +++ b/README.md @@ -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://: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` diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..a0872fa --- /dev/null +++ b/deploy.sh @@ -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 < 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/mediapi/config.py b/mediapi/config.py index 0b28767..dc58620 100644 --- a/mediapi/config.py +++ b/mediapi/config.py @@ -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", diff --git a/run.py b/run.py index ac048bc..a14f843 100644 --- a/run.py +++ b/run.py @@ -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) diff --git a/systemd/mediapi-app.service b/systemd/mediapi-app.service.template similarity index 72% rename from systemd/mediapi-app.service rename to systemd/mediapi-app.service.template index cbdb806..9fac16b 100644 --- a/systemd/mediapi-app.service +++ b/systemd/mediapi-app.service.template @@ -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 diff --git a/systemd/mediapi-mpv.service b/systemd/mediapi-mpv.service.template similarity index 95% rename from systemd/mediapi-mpv.service rename to systemd/mediapi-mpv.service.template index 3938607..a9413b5 100644 --- a/systemd/mediapi-mpv.service +++ b/systemd/mediapi-mpv.service.template @@ -4,7 +4,7 @@ After=local-fs.target [Service] Type=simple -User=jm +User=${MEDIAPI_USER} SupplementaryGroups=video render RuntimeDirectory=mediapi RuntimeDirectoryMode=0770