#!/usr/bin/env bash # # install_for_python - Provision the Python radio automation stack. # Idempotent: safe to re-run. Derives the service name from this file's # containing directory basename. Must be run as root. # set -euo pipefail if [[ $EUID -ne 0 ]]; then echo "ERROR: this installer must be run as root." >&2 exit 1 fi INSTALL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SERVICE_NAME="$(basename "$INSTALL_DIR")" UNIT_FILE="/etc/systemd/system/${SERVICE_NAME}.service" LIQUIDSOAP_USER="liquidsoap" echo "==> Installing ${SERVICE_NAME} (Python stack) from ${INSTALL_DIR}" # --------------------------------------------------------------------------- # 1. Base packages # --------------------------------------------------------------------------- echo "==> Ensuring base packages..." export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq \ python3 python3-pip python3-venv \ liquidsoap icecast2 jq curl ca-certificates >/dev/null # Ensure the dedicated service user exists. if ! id -u "$LIQUIDSOAP_USER" >/dev/null 2>&1; then useradd --system --create-home --shell /usr/sbin/nologin "$LIQUIDSOAP_USER" echo " created system user '${LIQUIDSOAP_USER}'" fi # --------------------------------------------------------------------------- # 2. Interactive configuration (defaults from existing config.json) # --------------------------------------------------------------------------- CONFIG_JSON="${INSTALL_DIR}/config.json" prompt() { local prompt_text="$1" default="${2:-}" local current="" if [[ -n "$default" ]]; then read -rp "${prompt_text} [${default}]: " current || true echo "${current:-$default}" else read -rp "${prompt_text}: " current || true echo "$current" fi } existing_storage="" ; existing_ic_host="" ; existing_ic_port="" existing_ic_mount="" ; existing_ic_user="" ; existing_ic_pass="" existing_gp_enable="" ; existing_gp_host="" ; existing_gp_user="" ; existing_gp_pass="" if [[ -f "$CONFIG_JSON" ]]; then echo "==> Found existing config.json; using it for defaults." existing_storage=$(jq -r '.storage // empty' "$CONFIG_JSON") existing_ic_host=$(jq -r '.icecast.host // empty' "$CONFIG_JSON") existing_ic_port=$(jq -r '.icecast.port // empty' "$CONFIG_JSON") existing_ic_mount=$(jq -r '.icecast.mount // empty' "$CONFIG_JSON") existing_ic_user=$(jq -r '.icecast.username // empty' "$CONFIG_JSON") existing_ic_pass=$(jq -r '.icecast.password // empty' "$CONFIG_JSON") existing_gp_enable=$(jq -r '.gpodder.enable // empty' "$CONFIG_JSON") existing_gp_host=$(jq -r '.gpodder.host // empty' "$CONFIG_JSON") existing_gp_user=$(jq -r '.gpodder.username // empty' "$CONFIG_JSON") existing_gp_pass=$(jq -r '.gpodder.password // empty' "$CONFIG_JSON") fi echo "" echo "--- Storage ---" STORAGE_PATH=$(prompt "Storage path (media + state + logs)" "${existing_storage:-/mnt/storage/radio}") [[ -z "$STORAGE_PATH" ]] && STORAGE_PATH="${existing_storage:-/mnt/storage/radio}" echo "" echo "--- Icecast (source credentials) ---" IC_HOST=$(prompt "Icecast host" "$existing_ic_host"); IC_HOST=${IC_HOST:-localhost} IC_PORT=$(prompt "Icecast source port" "$existing_ic_port"); IC_PORT=${IC_PORT:-7777} IC_MOUNT=$(prompt "Mount point" "$existing_ic_mount"); IC_MOUNT=${IC_MOUNT:-/data} IC_USER=$(prompt "Source username" "$existing_ic_user"); IC_USER=${IC_USER:-source} read -rsp "Source password: " IC_PASS || true; echo; IC_PASS=${IC_PASS:-$existing_ic_pass} echo "" echo "--- gPodder sync (optional) ---" GP_ENABLE=$(prompt "Enable gPodder sync? (true/false)" "$existing_gp_enable"); GP_ENABLE=${GP_ENABLE:-false} GP_HOST=$(prompt "gPodder host" "$existing_gp_host"); GP_HOST=${GP_HOST:-https://gpodder.net} GP_USER=$(prompt "gPodder username" "$existing_gp_user") read -rsp "gPodder password: " GP_PASS || true; echo; GP_PASS=${GP_PASS:-$existing_gp_pass} echo "" echo "Summary:" echo " Storage : $STORAGE_PATH" echo " Icecast : $IC_USER@$IC_HOST:$IC_PORT mount=$IC_MOUNT" echo " gPodder : enabled=$GP_ENABLE ($GP_USER @ $GP_HOST)" read -rp "Proceed? [y/N]: " confirm || true case "$confirm" in [Yy]*) ;; *) echo "Aborted."; exit 0 ;; esac # --------------------------------------------------------------------------- # 3. Create the directory tree # --------------------------------------------------------------------------- echo "==> Creating directory tree under $STORAGE_PATH ..." mkdir -p "$STORAGE_PATH"/{music,podcasts,jingles,announcements,state,playlists,logs} chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$STORAGE_PATH" # --------------------------------------------------------------------------- # 4. Write config.json (mode 600) # --------------------------------------------------------------------------- echo "==> Writing $CONFIG_JSON ..." cat > "$CONFIG_JSON" < Initializing SQLite databases ..." python3 - "$STORAGE_PATH/state" <<'PYINIT' import sqlite3, sys, os state_dir = sys.argv[1] subs = os.path.join(state_dir, "subscriptions.db") played = os.path.join(state_dir, "played.db") conn = sqlite3.connect(subs) conn.executescript(""" CREATE TABLE IF NOT EXISTS shows ( slug TEXT PRIMARY KEY, guid TEXT NOT NULL UNIQUE, name TEXT NOT NULL, feed_url TEXT NOT NULL UNIQUE, source TEXT DEFAULT 'manual', opml_import INTEGER DEFAULT 0, archived INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')) ); """) conn.commit(); conn.close() conn = sqlite3.connect(played) conn.executescript(""" CREATE TABLE IF NOT EXISTS episodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, show_slug TEXT NOT NULL, guid TEXT NOT NULL, title TEXT, file_path TEXT, enclosure_url TEXT, runlength INTEGER, played INTEGER DEFAULT 0, played_at TEXT, UNIQUE(show_slug, guid) ); CREATE INDEX IF NOT EXISTS idx_episodes_show_played ON episodes (show_slug, played); """) conn.commit(); conn.close() print(" subscriptions.db and played.db initialized.") PYINIT chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$STORAGE_PATH/state" # --------------------------------------------------------------------------- # 6. Python virtualenv + dependencies # --------------------------------------------------------------------------- VENV="${INSTALL_DIR}/.venv" if [[ ! -x "${VENV}/bin/python" ]]; then echo "==> Creating Python venv at ${VENV} ..." python3 -m venv "$VENV" fi echo "==> Installing Python dependencies (feedparser, requests) ..." "${VENV}/bin/pip" install --quiet --upgrade pip "${VENV}/bin/pip" install --quiet feedparser requests # Make sure the liquidsoap user can traverse the install dir and use the venv. chmod o+x "$INSTALL_DIR" chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$VENV" # --------------------------------------------------------------------------- # 7. systemd unit (named after the directory) # --------------------------------------------------------------------------- echo "==> Writing systemd unit ${UNIT_FILE} ..." cat > "$UNIT_FILE" < "${INSTALL_DIR}/station_runner.py" <<'PYRUN' import os, subprocess, sys install_dir = os.path.dirname(os.path.abspath(__file__)) os.chdir(install_dir) sys.exit(subprocess.call(["liquidsoap", os.path.join(install_dir, "station.liq")])) PYRUN chown "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "${INSTALL_DIR}/station_runner.py" systemctl daemon-reload # --------------------------------------------------------------------------- # 8. Cron jobs — installed into the liquidsoap user's crontab so all # database/media writes happen under the same identity as the service. # --------------------------------------------------------------------------- echo "==> Setting up cron jobs under user '${LIQUIDSOAP_USER}' ..." FETCH_CRON="0 * * * * cd ${INSTALL_DIR} && ./.venv/bin/python fetch_podcasts.py >> ${STORAGE_PATH}/logs/fetch.log 2>&1" UPDATE_CRON="30 * * * * cd ${INSTALL_DIR} && ./.venv/bin/python update_playlists.py >> ${STORAGE_PATH}/logs/update.log 2>&1" # Remove any stale entries from the liquidsoap user's crontab, then add ours. sudo -u "$LIQUIDSOAP_USER" crontab -l 2>/dev/null | grep -vF "fetch_podcasts.py" | grep -vF "update_playlists.py" > /tmp/cron_ls_py.$$ || true echo "$FETCH_CRON" >> /tmp/cron_ls_py.$$ echo "$UPDATE_CRON" >> /tmp/cron_ls_py.$$ sudo -u "$LIQUIDSOAP_USER" crontab /tmp/cron_ls_py.$$ rm -f /tmp/cron_ls_py.$$ # Also scrub these from root's crontab in case an earlier install put them there. crontab -l 2>/dev/null | grep -vF "fetch_podcasts.py" | grep -vF "update_playlists.py" > /tmp/cron_root_py.$$ || true crontab /tmp/cron_root_py.$$ rm -f /tmp/cron_root_py.$$ # --------------------------------------------------------------------------- # 9. Enable services # --------------------------------------------------------------------------- systemctl enable icecast2 "$SERVICE_NAME" echo "==> Enabling icecast2 and ${SERVICE_NAME} at boot." echo "" echo "============================================================" echo " Installation complete." echo "" echo " Next steps:" echo " 1. Drop background music into ${STORAGE_PATH}/music/" echo " 2. Edit ${INSTALL_DIR}/schedule.txt for scheduled shows" echo " 3. Review ${INSTALL_DIR}/station.liq" echo " 4. Start: systemctl start icecast2 ${SERVICE_NAME}" echo "============================================================"