#!/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 ---------------------------------------------------- # First run on a fresh Pi may have no .env yet -- seed it from the example so the # script can proceed, but make it loud: the defaults ship an insecure AP. if [[ ! -f .env ]]; then echo "WARNING: .env not found -- creating it from .env.example." >&2 echo " Edit .env with your real AP/login/Kodi passwords, then re-run." >&2 cp .env.example .env fi set -a # shellcheck disable=SC1091 source .env set +a MEDIAPI_USER="${MEDIAPI_USER:-$(id -un)}" MEDIAPI_PORT="${MEDIAPI_PORT:-8080}" UV="" # set by bootstrap_system once uv is installed # --- 0. refuse to run on a read-only overlay ------------------------ if findmnt -no FSTYPE / | grep -q overlay; then cat >&2 < Checking out '$TARGET_REF' ..." git checkout --detach "$TARGET_REF" fi CURRENT_REF="$(git rev-parse HEAD)" CURRENT_DESC="$(git describe --tags --always 2>/dev/null || echo "$CURRENT_REF")" echo "==> Deploying $CURRENT_DESC ($CURRENT_REF)" if [[ -n "$PREV_GOOD" && "$PREV_GOOD" != "$CURRENT_REF" ]]; then echo " (last healthy deploy was $PREV_GOOD -- rollback target if this fails)" fi # --- system bootstrap (fresh Pi: assume ONLY git is installed) ------- # Installs everything else the deploy needs so a bare Raspberry Pi OS Lite goes # from "git clone + ./install.sh" to a working player. NetworkManager (nmcli) # and raspi-config already ship on Pi OS. Sets the global UV path afterwards. bootstrap_system() { echo "==> apt update + base packages (curl, python3) ..." sudo apt-get update -qq sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ curl ca-certificates python3 if ! command -v uv >/dev/null 2>&1 && [[ ! -x "$HOME/.local/bin/uv" ]]; then echo "==> Installing uv ..." curl -LsSf https://astral.sh/uv/install.sh | sh fi UV="$(command -v uv || echo "$HOME/.local/bin/uv")" if [[ ! -x "$UV" ]]; then echo "ERROR: uv is still not available at '$UV' after install." >&2 exit 1 fi } # --- render + install systemd units (used again on rollback) -------- install_units() { for unit in mediapi-kodi 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 # Kodi (the player) runs as MEDIAPI_USER on GBM/KMS and needs these groups to # reach the GPU/DRM, audio, input and console devices. Idempotent; systemd # picks up the new membership when it (re)starts the service below. if ! id -nG "${MEDIAPI_USER}" | tr ' ' '\n' | grep -qx render; then echo "==> Adding '${MEDIAPI_USER}' to video,render,input,audio,tty groups ..." sudo usermod -aG video,render,input,audio,tty "${MEDIAPI_USER}" fi # Install Kodi (the actual media player -- mediapi just drives it via JSON-RPC). if ! command -v kodi-standalone >/dev/null 2>&1; then echo "==> Installing Kodi ..." sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends kodi fi # Ensure the media browse roots exist so browsing works and there's a place to # copy content into. (mkdir -p is a no-op if it's already a mount point.) IFS=':' read -ra _media_roots <<< "${MEDIAPI_MEDIA_ROOTS:-/localmedia}" for r in "${_media_roots[@]}"; do [[ -n "$r" && ! -d "$r" ]] || continue echo "==> Creating media root $r ..." sudo mkdir -p "$r" sudo chown "${MEDIAPI_USER}:${MEDIAPI_USER}" "$r" done 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 ..." install_units sudo systemctl enable mediapi-kodi mediapi-app >/dev/null 2>&1 || true # Enable Kodi's JSON-RPC web server so mediapi can control it. Kodi rewrites # guisettings.xml on exit, so seed it while Kodi is stopped, then start. KODI_HOME="$(eval echo "~${MEDIAPI_USER}")/.kodi" echo "==> Enabling Kodi web server (JSON-RPC) on port ${MEDIAPI_KODI_PORT:-8090} ..." sudo systemctl stop mediapi-kodi 2>/dev/null || true sudo -u "${MEDIAPI_USER}" mkdir -p "${KODI_HOME}/userdata" sudo -u "${MEDIAPI_USER}" \ MEDIAPI_KODI_PORT="${MEDIAPI_KODI_PORT:-8090}" \ MEDIAPI_KODI_USER="${MEDIAPI_KODI_USER:-kodi}" \ MEDIAPI_KODI_PASSWORD="${MEDIAPI_KODI_PASSWORD:-kodi}" \ python3 "${PROJECT_DIR}/scripts/configure-kodi.py" "${KODI_HOME}/userdata/guisettings.xml" echo "==> Starting services ..." sudo systemctl restart mediapi-kodi sudo systemctl restart mediapi-app # Route Kodi's audio to HDMI. Kodi otherwise defaults to the Pi's analog jack # (bcm2835 Headphones). Done live over JSON-RPC once Kodi's web server is up # (more robust than seeding guisettings, since Kodi validates the device). local kodi_url="http://${MEDIAPI_KODI_USER:-kodi}:${MEDIAPI_KODI_PASSWORD:-kodi}@127.0.0.1:${MEDIAPI_KODI_PORT:-8090}/jsonrpc" local audio_dev="${MEDIAPI_KODI_AUDIO_DEVICE:-ALSA:hdmi:CARD=vc4hdmi0,DEV=0|vc4-hdmi-0}" echo "==> Setting Kodi audio output to HDMI ..." for _ in $(seq 1 30); do if curl -fsS --max-time 3 "$kodi_url" -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"JSONRPC.Ping"}' 2>/dev/null | grep -q pong; then curl -fsS --max-time 5 "$kodi_url" -H 'content-type: application/json' \ -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"Settings.SetSettingValue\",\"params\":{\"setting\":\"audiooutput.audiodevice\",\"value\":\"${audio_dev}\"}}" >/dev/null \ && echo " audio -> ${audio_dev}" || echo " WARNING: could not set Kodi audio device" break fi sleep 1 done } # --- 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 ---------------------------------------------------- bootstrap_system 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-kodi 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