#!/usr/bin/env bash # # install_for_jruby - Provision the JRuby 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" JRUBY_HOME_PINNED="/opt/jruby" JRUBY_VERSION="10.1.1.0" GEMS_DIR="${INSTALL_DIR}/.gems" echo "==> Installing ${SERVICE_NAME} (JRuby stack) from ${INSTALL_DIR}" # --------------------------------------------------------------------------- # 1. Base packages # --------------------------------------------------------------------------- echo "==> Ensuring base packages..." export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq \ liquidsoap icecast2 jq curl unzip ca-certificates >/dev/null 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. Java detection / install # --------------------------------------------------------------------------- have_java() { command -v java >/dev/null 2>&1 || return 1 local major major=$(java -version 2>&1 | awk -F '"' '/version/ {print $2}' | cut -d. -f1) [[ "$major" == "1" ]] && major=$(java -version 2>&1 | awk -F '"' '/version/ {print $2}' | cut -d. -f2) [[ -n "$major" && "$major" -ge 21 ]] } if have_java; then echo "==> Reusing existing JVM ($(java -version 2>&1 | head -1))." else echo "==> No suitable JVM found; installing OpenJDK 21 headless..." apt-get install -y -qq openjdk-21-jdk-headless >/dev/null fi # --------------------------------------------------------------------------- # 3. JRuby detection / install # --------------------------------------------------------------------------- have_jruby() { if [[ -x "${JRUBY_HOME_PINNED}/bin/jruby" ]]; then echo "${JRUBY_HOME_PINNED}/bin/jruby"; return 0 fi if command -v jruby >/dev/null 2>&1; then command -v jruby; return 0 fi return 1 } if JRUBY_BIN=$(have_jruby); then echo "==> Reusing existing JRuby at ${JRUBY_BIN}." else echo "==> Downloading JRuby ${JRUBY_VERSION} into ${JRUBY_HOME_PINNED} ..." mkdir -p "$JRUBY_HOME_PINNED" TARBALL="/tmp/jruby-${JRUBY_VERSION}-bin.tar.gz" URL="https://repo1.maven.org/maven2/org/jruby/jruby-dist/${JRUBY_VERSION}/jruby-dist-${JRUBY_VERSION}-bin.tar.gz" curl -fsSL "$URL" -o "$TARBALL" tar -xzf "$TARBALL" -C "$JRUBY_HOME_PINNED" --strip-components=1 rm -f "$TARBALL" JRUBY_BIN="${JRUBY_HOME_PINNED}/bin/jruby" fi # --------------------------------------------------------------------------- # 4. Register via update-alternatives # --------------------------------------------------------------------------- echo "==> Integrating JRuby via update-alternatives ..." for tool in jruby gem bundle rake irb; do target="${JRUBY_BIN%/*}/${tool}" if [[ -x "$target" ]]; then ln -sf "$target" "/usr/local/bin/${tool}" update-alternatives --install "/usr/local/bin/${tool}" "${tool}" "$target" 100 || true fi done # --------------------------------------------------------------------------- # 5. Gems (one at a time, raised heap) # --------------------------------------------------------------------------- export GEM_HOME="$GEMS_DIR" export GEM_PATH="$GEMS_DIR" export JRUBY_OPTS="-J-Xmx1g -J-Xss512k" echo "==> Installing gems one at a time into ${GEMS_DIR} ..." "$JRUBY_BIN" -S gem install --no-document jdbc-sqlite3 "$JRUBY_BIN" -S gem install --no-document json # Give the service user ownership of the gem cache so cron runs work. chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$GEMS_DIR" # --------------------------------------------------------------------------- # 6. 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 # --------------------------------------------------------------------------- # 7. 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" # --------------------------------------------------------------------------- # 8. Write config.json (mode 600) # --------------------------------------------------------------------------- echo "==> Writing $CONFIG_JSON ..." cat > "$CONFIG_JSON" < Initializing SQLite databases via jdbc-sqlite3 ..." GEM_HOME="$GEMS_DIR" GEM_PATH="$GEMS_DIR" "$JRUBY_BIN" -e " require 'jdbc/sqlite3' require 'java' state_dir = ARGV[0] JDBC::Database.new(\"jdbc:sqlite:\#{state_dir}/subscriptions.db\") do |db| db.execute(%Q{ 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')) ) }) end JDBC::Database.new(\"jdbc:sqlite:\#{state_dir}/played.db\") do |db| db.execute(%Q{ 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) ) }) db.execute(%Q{ CREATE INDEX IF NOT EXISTS idx_episodes_show_played ON episodes (show_slug, played) }) end puts ' subscriptions.db and played.db initialized.' " "$STORAGE_PATH/state" chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$STORAGE_PATH/state" # --------------------------------------------------------------------------- # 10. systemd unit (named after the directory) # --------------------------------------------------------------------------- echo "==> Writing systemd unit ${UNIT_FILE} ..." cat > "$UNIT_FILE" < "${INSTALL_DIR}/station.rb" <<'RBRUN' Dir.chdir(File.expand_path(__dir__)) exec("liquidsoap", File.expand_path("station.liq")) RBRUN chown "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "${INSTALL_DIR}/station.rb" # Allow the service user to traverse the install dir. chmod o+x "$INSTALL_DIR" systemctl daemon-reload # --------------------------------------------------------------------------- # 11. 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_PREFIX="cd ${INSTALL_DIR} && GEM_HOME=${GEMS_DIR} GEM_PATH=${GEMS_DIR} ${JRUBY_BIN} -S fetch_podcasts.rb" UPDATE_PREFIX="cd ${INSTALL_DIR} && GEM_HOME=${GEMS_DIR} GEM_PATH=${GEMS_DIR} ${JRUBY_BIN} -S update_playlists.rb" FETCH_CRON="0 * * * * ${FETCH_PREFIX} >> ${STORAGE_PATH}/logs/fetch.log 2>&1" UPDATE_CRON="30 * * * * ${UPDATE_PREFIX} >> ${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.rb" | grep -vF "update_playlists.rb" > /tmp/cron_ls_rb.$$ || true echo "$FETCH_CRON" >> /tmp/cron_ls_rb.$$ echo "$UPDATE_CRON" >> /tmp/cron_ls_rb.$$ sudo -u "$LIQUIDSOAP_USER" crontab /tmp/cron_ls_rb.$$ rm -f /tmp/cron_ls_rb.$$ # Also scrub these from root's crontab in case an earlier install put them there. crontab -l 2>/dev/null | grep -vF "fetch_podcasts.rb" | grep -vF "update_playlists.rb" > /tmp/cron_root_rb.$$ || true crontab /tmp/cron_root_rb.$$ rm -f /tmp/cron_root_rb.$$ # --------------------------------------------------------------------------- # 12. 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 "============================================================"