mediapi/install.sh
Jean-Michel Tremblay 99b5c27e65 install.sh: route Kodi audio to HDMI + ensure media roots exist
- After Kodi starts, set audiooutput.audiodevice to the HDMI sink via JSON-RPC
  (Kodi otherwise defaults to the analog jack). Waits for the web server, is
  overridable via MEDIAPI_KODI_AUDIO_DEVICE.
- mkdir the configured MEDIAPI_MEDIA_ROOTS so browsing works and there's a spot
  to copy content into.

Verified live on the Pi: playback + HDMI audio work end to end via the app's
Player.Open path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:09:08 -04:00

264 lines
10 KiB
Bash
Executable file

#!/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 <<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
# --- 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