#!/bin/bash # # install_for_jruby - Setup for liquidsoap radio automation stack (JRuby edition) # Target: Linux Mint 22.3 (Ubuntu 24.04 Noble based) # # Usage: sudo ./install_for_jruby # Must be run from within the target directory (e.g., /srv/audio/) # # Detects existing Java/JRuby; installs OpenJDK 21 and JRuby 10.1.1.0 into /opt # only if missing, integrating JRuby via update-alternatives. Installs gems one # at a time with a raised JVM heap to avoid OOM on low-RAM hosts. # set -euo pipefail # ------------------------------------------------------- # Determine install directory from script location # ------------------------------------------------------- SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")" INSTALL_DIR="$(dirname "$SCRIPT_PATH")" SERVICE_NAME="$(basename "$INSTALL_DIR")" LIQUIDSOAP_USER="liquidsoap" JRUBY_VERSION="10.1.1.0" JRUBY_HOME="/opt/jruby-${JRUBY_VERSION}" GEM_HOME_LOCAL="$INSTALL_DIR/.gems" echo "=== Radio Automation Installer (JRuby) ===" echo "Install directory: $INSTALL_DIR" echo "Service name: $SERVICE_NAME" echo "" # ------------------------------------------------------- # 0. Validate environment # ------------------------------------------------------- if [ "$(id -u)" -ne 0 ]; then echo "ERROR: This script must be run as root (sudo)." exit 1 fi if [ ! -d "$INSTALL_DIR" ]; then echo "ERROR: Install directory $INSTALL_DIR does not exist." exit 1 fi if [ ! -w "$INSTALL_DIR" ]; then echo "ERROR: Cannot write to $INSTALL_DIR. Check permissions." exit 1 fi REQUIRED_DIRS=( "music" "podcasts" "jingles" "announcements" "playlists" "state" "logs" ) # ------------------------------------------------------- # Helper: prompt with default value # ------------------------------------------------------- prompt() { local var_name="$1" local prompt_text="$2" local default_value="${3:-}" local hide_input="${4:-false}" if [ -n "$default_value" ]; then read -rp "$prompt_text [$default_value]: " input if [ -z "$input" ]; then eval "$var_name=$default_value" else eval "$var_name=\$input" fi else if [ "$hide_input" = "true" ]; then read -rsp "$prompt_text: " input echo "" eval "$var_name=\$input" else read -rp "$prompt_text: " input eval "$var_name=\$input" fi fi } prompt_confirm() { local var_name="$1" local prompt_text="$2" local default_value="${3:-no}" read -rp "$prompt_text [$default_value]: " input if [ -z "$input" ]; then input="$default_value" fi case "$input" in [Yy]* ) eval "$var_name=true" ;; * ) eval "$var_name=false" ;; esac } # ------------------------------------------------------- # 1. Interactive configuration # ------------------------------------------------------- CONFIG_FILE="$INSTALL_DIR/config.json" EXISTING_CONFIG=false if [ -f "$CONFIG_FILE" ]; then EXISTING_CONFIG=true echo "--- Loading existing config.json for defaults ---" ICECAST_HOST_DEFAULT=$(jq -r '.icecast.host // "localhost"' "$CONFIG_FILE") ICECAST_PORT_DEFAULT=$(jq -r '.icecast.port // 7777' "$CONFIG_FILE") ICECAST_MOUNT_DEFAULT=$(jq -r '.icecast.mount // "/audio.mp3"' "$CONFIG_FILE") ICECAST_USERNAME_DEFAULT=$(jq -r '.icecast.username // "source"' "$CONFIG_FILE") GPODDER_ENABLE_DEFAULT=$(jq -r 'if .gpodder.enable == true then "yes" else "no" end' "$CONFIG_FILE") GPODDER_HOST_DEFAULT=$(jq -r '.gpodder.host // "https://gpodder.net"' "$CONFIG_FILE") GPODDER_USERNAME_DEFAULT=$(jq -r '.gpodder.username // ""' "$CONFIG_FILE") else ICECAST_HOST_DEFAULT="localhost" ICECAST_PORT_DEFAULT="7777" ICECAST_MOUNT_DEFAULT="/audio.mp3" ICECAST_USERNAME_DEFAULT="source" GPODDER_ENABLE_DEFAULT="no" GPODDER_HOST_DEFAULT="https://gpodder.net" GPODDER_USERNAME_DEFAULT="" fi echo "" echo "--- Icecast Settings ---" echo "" ICECAST_HOST="" ICECAST_PORT="" ICECAST_MOUNT="" ICECAST_USERNAME="" ICECAST_PASSWORD="" while true; do prompt ICECAST_HOST "Icecast host" "$ICECAST_HOST_DEFAULT" [[ "$ICECAST_HOST" =~ ^[a-zA-Z0-9._-]+$ ]] && break echo "Invalid hostname. Try again." done while true; do prompt ICECAST_PORT "Source port" "$ICECAST_PORT_DEFAULT" [[ "$ICECAST_PORT" =~ ^[0-9]{1,5}$ ]] && (( ICECAST_PORT >= 1 )) && (( ICECAST_PORT <= 65535 )) && break echo "Port must be a number between 1 and 65535. Try again." done while true; do prompt ICECAST_MOUNT "Mount point" "$ICECAST_MOUNT_DEFAULT" [[ "$ICECAST_MOUNT" == /* ]] && break echo "Mount point must start with /. Try again." done prompt ICECAST_USERNAME "Source username" "$ICECAST_USERNAME_DEFAULT" while true; do prompt ICECAST_PASSWORD "Source password" "" "true" [ -n "$ICECAST_PASSWORD" ] && break echo "Password cannot be empty. Try again." done echo "" echo "--- gPodder.net Sync Settings ---" echo "" GPODDER_ENABLE=false if [ "$GPODDER_ENABLE_DEFAULT" = "yes" ]; then prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "yes" else prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "no" fi GPODDER_HOST="" GPODDER_USERNAME="" GPODDER_PASSWORD="" if [ "$GPODDER_ENABLE" = "true" ]; then prompt GPODDER_HOST "gPodder host" "$GPODDER_HOST_DEFAULT" while true; do prompt GPODDER_USERNAME "gPodder username/email" "$GPODDER_USERNAME_DEFAULT" [ -n "$GPODDER_USERNAME" ] && break echo "Username cannot be empty. Try again." done while true; do prompt GPODDER_PASSWORD "gPodder password" "" "true" [ -n "$GPODDER_PASSWORD" ] && break echo "Password cannot be empty. Try again." done fi echo "" echo "--- Summary ---" echo " Icecast: ${ICECAST_HOST}:${ICECAST_PORT}${ICECAST_MOUNT} (user: ${ICECAST_USERNAME})" echo " gPodder: $( [ "$GPODDER_ENABLE" = "true" ] && echo "enabled (${GPODDER_USERNAME}@${GPODDER_HOST})" || echo "disabled" )" echo "" CONFIRM_INSTALL=false prompt_confirm CONFIRM_INSTALL "Proceed with these settings?" "yes" if [ "$CONFIRM_INSTALL" != "true" ]; then echo "Aborted by user." exit 0 fi # ------------------------------------------------------- # 2. Base system packages (always needed regardless of Java state) # ------------------------------------------------------- echo "" echo "--- Installing base system packages ---" apt-get update apt-get install -y \ liquidsoap \ icecast2 \ jq \ curl \ ca-certificates \ unzip # ------------------------------------------------------- # 3. Detect / install Java (OpenJDK 21 headless) # ------------------------------------------------------- echo "" echo "--- Checking Java ---" JAVA_OK=false if command -v java >/dev/null 2>&1; then JAVA_VER_LINE=$(java -version 2>&1 | head -1) # Extract major version: handles both '"21.0.x"' and '1.8.0_xxx' styles JAVA_MAJOR=$(echo "$JAVA_VER_LINE" | sed -nE 's/.*"([0-9]+)(\.[0-9]+)?".*/\1/p') if [ -n "$JAVA_MAJOR" ] && [ "$JAVA_MAJOR" -ge 21 ]; then JAVA_OK=true echo " Found suitable Java ($JAVA_VER_LINE)" else echo " Java found but version too old ($JAVA_VER_LINE); installing OpenJDK 21." fi else echo " No Java detected; installing OpenJDK 21." fi if [ "$JAVA_OK" != "true" ]; then apt-get install -y openjdk-21-jre-headless echo " Installed OpenJDK 21." fi # ------------------------------------------------------- # 4. Detect / install JRuby # ------------------------------------------------------- echo "" echo "--- Checking JRuby ---" JRUBY_FOUND="" # Preferred: our pinned home already present if [ -x "$JRUBY_HOME/bin/jruby" ]; then JRUBY_FOUND="$JRUBY_HOME/bin/jruby" echo " Found JRuby at $JRUBY_HOME" fi # Fallback: any jruby resolvable on PATH (system, other /opt version, etc.) if [ -z "$JRUBY_FOUND" ] && command -v jruby >/dev/null 2>&1; then DETECTED_JRUBY="$(command -v jruby)" DETECTED_JRUBY="$(readlink -f "$DETECTED_JRUBY")" JRUBY_FOUND="$DETECTED_JRUBY" echo " Found existing JRuby on PATH: $DETECTED_JRUBY" echo " ($( "$DETECTED_JRUBY" -v 2>/dev/null | head -1 ))" fi if [ -z "$JRUBY_FOUND" ]; then echo " No JRuby detected; installing JRuby ${JRUBY_VERSION} to /opt ..." JRUBY_TARBALL="/tmp/jruby-dist-${JRUBY_VERSION}-bin.tar.gz" # Post-9.1.14.0 releases are distributed via Maven Central as jruby-dist-* curl -fsSL "https://repo1.maven.org/maven2/org/jruby/jruby-dist/${JRUBY_VERSION}/jruby-dist-${JRUBY_VERSION}-bin.tar.gz" -o "$JRUBY_TARBALL" mkdir -p /opt tar -xzf "$JRUBY_TARBALL" -C /opt rm -f "$JRUBY_TARBALL" ln -sfn "$JRUBY_HOME" /opt/jruby-current JRUBY_FOUND="$JRUBY_HOME/bin/jruby" echo " Installed JRuby to $JRUBY_HOME" fi # Derive the effective JRuby home from whichever binary we ended up using EFF_JRUBY_BIN="$JRUBY_FOUND" EFF_JRUBY_HOME="$(dirname "$(dirname "$EFF_JRUBY_BIN")")" export PATH="$EFF_JRUBY_HOME/bin:$PATH" # ------------------------------------------------------- # 5. Integrate JRuby with the system via update-alternatives # ------------------------------------------------------- echo "--- Integrating JRuby via update-alternatives ---" update-alternatives --install /usr/local/bin/jruby jruby "$EFF_JRUBY_BIN" 100 # Also expose bundle/gem/rake under alternatives so they resolve system-wide for tool in bundle gem rake irb; do if [ -x "$EFF_JRUBY_HOME/bin/$tool" ]; then update-alternatives --install "/usr/local/bin/$tool" "$tool" "$EFF_JRUBY_HOME/bin/$tool" 100 fi done echo " Registered jruby -> $EFF_JRUBY_BIN (priority 100)" # Sanity-check that the integrated binary actually runs if ! "$EFF_JRUBY_BIN" -v >/dev/null 2>&1; then echo "ERROR: Integrated JRuby binary failed to execute. Aborting." exit 1 fi echo " Verified: $("${EFF_JRUBY_BIN}" -v 2>/dev/null | head -1)" # ------------------------------------------------------- # 6. Install gems into a project-local gem home (one at a time) # ------------------------------------------------------- echo "--- Installing Ruby gems ---" export GEM_HOME="$GEM_HOME_LOCAL" export GEM_PATH="$GEM_HOME_LOCAL" # Raise the JVM heap for gem operations. JRuby defaults to ~500MB, which is # too small when bundler resolves multiple gems at once on a low-RAM host. export JRUBY_OPTS="-J-Xmx1g -J-Xss512k" install_gem() { local name="$1" echo " Installing gem: $name" if ! "$EFF_JRUBY_BIN" -S gem install "$name" --no-document; then echo "ERROR: Failed to install gem '$name'." exit 1 fi } # Install bundler first (needed to drive bundle install), then each dependency # separately so a single OOM can't take down the whole set. install_gem bundler install_gem nokogiri install_gem sqlite3 install_gem json # Lock versions against the Gemfile now that every gem is present. (cd "$INSTALL_DIR" && \ GEM_HOME="$GEM_HOME_LOCAL" GEM_PATH="$GEM_HOME_LOCAL" \ JRUBY_OPTS="$JRUBY_OPTS" \ "$EFF_JRUBY_BIN" -S bundle install --quiet) echo " Gems installed to $GEM_HOME_LOCAL" # ------------------------------------------------------- # 7. Create and verify directory structure # ------------------------------------------------------- echo "--- Creating directory structure ---" MISSING=() for dir in "${REQUIRED_DIRS[@]}"; do full_path="$INSTALL_DIR/$dir" if [ ! -d "$full_path" ]; then mkdir -p "$full_path" MISSING+=("$dir") fi done if [ ${#MISSING[@]} -gt 0 ]; then echo " Created: ${MISSING[*]}" else echo " All directories already present." fi FAILED=() for dir in "${REQUIRED_DIRS[@]}"; do if [ ! -d "$INSTALL_DIR/$dir" ]; then FAILED+=("$dir") fi done if [ ${#FAILED[@]} -gt 0 ]; then echo "ERROR: Failed to create directories: ${FAILED[*]}" exit 1 fi # ------------------------------------------------------- # 8. Generate config.json from collected settings # ------------------------------------------------------- echo "--- Generating config.json ---" cat > "$CONFIG_FILE" << EOF { "icecast": { "host": "${ICECAST_HOST}", "port": ${ICECAST_PORT}, "mount": "${ICECAST_MOUNT}", "username": "${ICECAST_USERNAME}", "password": "${ICECAST_PASSWORD}" }, "gpodder": { "enable": ${GPODDER_ENABLE}, "host": "${GPODDER_HOST}", "username": "${GPODDER_USERNAME}", "password": "${GPODDER_PASSWORD}" } } EOF chmod 600 "$CONFIG_FILE" echo " Written: $CONFIG_FILE" # ------------------------------------------------------- # 9. Create schedule.txt template (if not present) # ------------------------------------------------------- SCHEDULE_FILE="$INSTALL_DIR/schedule.txt" if [ ! -f "$SCHEDULE_FILE" ]; then echo "--- Creating schedule.txt template ---" cat > "$SCHEDULE_FILE" << 'EOF' # Radio Schedule # Format: min hour dom mon dow TYPE TARGET [RUNLENGTH_SECONDS] # TYPE: show | stream # RUNLENGTH required for stream entries, ignored for show entries # # Examples: # 0 8 * * 2 show hardcore_history # 0 6 * * 1 stream http://example.org:8000/live.mp3 3600 EOF else echo "--- schedule.txt already exists, skipping ---" fi # ------------------------------------------------------- # 10. Verify required project files exist # ------------------------------------------------------- echo "--- Verifying project files ---" PROJECT_FILES=("station.liq" "fetch_podcasts.rb" "update_playlists.rb" "Gemfile") FILE_ERRORS=() for f in "${PROJECT_FILES[@]}"; do if [ ! -f "$INSTALL_DIR/$f" ]; then FILE_ERRORS+=("$f") else echo " Found: $f" fi done if [ ${#FILE_ERRORS[@]} -gt 0 ]; then echo "WARNING: Missing expected files: ${FILE_ERRORS[*]}" echo " The service will not start until these are in place." fi # ------------------------------------------------------- # 11. Create systemd service named after the directory # ------------------------------------------------------- echo "--- Creating systemd service: ${SERVICE_NAME}.service ---" cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF [Unit] Description=Liquidsoap radio automation (${SERVICE_NAME}) After=network.target icecast2.service [Service] Type=simple User=${LIQUIDSOAP_USER} WorkingDirectory=${INSTALL_DIR} Environment=GEM_HOME=${GEM_HOME_LOCAL} Environment=GEM_PATH=${GEM_HOME_LOCAL} ExecStart=/usr/bin/liquidsoap ${INSTALL_DIR}/station.liq Restart=on-failure RestartSec=5 StandardOutput=append:${INSTALL_DIR}/logs/liquidsoap.log StandardError=append:${INSTALL_DIR}/logs/liquidsoap.log [Install] WantedBy=multi-user.target EOF # ------------------------------------------------------- # 12. Create cron jobs (hourly fetch + hourly playlist update) # ------------------------------------------------------- echo "--- Setting up cron jobs ---" # Cron runs with a minimal PATH, so reference the absolute integrated path. RUBY_RUN="cd $INSTALL_DIR && GEM_HOME=$GEM_HOME_LOCAL GEM_PATH=$GEM_HOME_LOCAL /usr/local/bin/jruby -S bundle exec" FETCH_CRON="0 * * * * $RUBY_RUN fetch_podcasts.rb >> $INSTALL_DIR/logs/fetch.log 2>&1" UPDATE_CRON="30 * * * * $RUBY_RUN update_playlists.rb >> $INSTALL_DIR/logs/update.log 2>&1" EXISTING_CRON=$(crontab -l -u "$LIQUIDSOAP_USER" 2>/dev/null || true) CLEANED_CRON=$(echo "$EXISTING_CRON" | grep -v "fetch_podcasts.rb\|update_playlists.rb" || true) if ! echo "$EXISTING_CRON" | grep -q "fetch_podcasts.rb"; then CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON }$FETCH_CRON" echo " Added: fetch_podcasts.rb (hourly)" else echo " Skipped: fetch_podcasts.rb job already exists" fi if ! echo "$EXISTING_CRON" | grep -q "update_playlists.rb"; then CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON }$UPDATE_CRON" echo " Added: update_playlists.rb (hourly at :30)" else echo " Skipped: update_playlists.rb job already exists" fi echo "$CLEANED_CRON" | crontab -u "$LIQUIDSOAP_USER" - # ------------------------------------------------------- # 13. Permissions # ------------------------------------------------------- echo "--- Setting ownership and permissions ---" id -u "$LIQUIDSOAP_USER" &>/dev/null || useradd --system --shell /usr/sbin/nologin "$LIQUIDSOAP_USER" chown -R "${LIQUIDSOAP_USER}:${LIQUIDSOAP_USER}" "$INSTALL_DIR" chmod 700 "$INSTALL_DIR/state" chmod 700 "$INSTALL_DIR/logs" systemctl daemon-reload # ------------------------------------------------------- # 14. Final verification # ------------------------------------------------------- echo "" echo "=== Final Verification ===" ALL_OK=true for dir in "${REQUIRED_DIRS[@]}"; do if [ -d "$INSTALL_DIR/$dir" ] && [ -r "$INSTALL_DIR/$dir" ] && [ -w "$INSTALL_DIR/$dir" ]; then printf " [OK] %-20s\n" "$dir/" else printf " [FAIL] %-20s (missing or inaccessible)\n" "$dir/" ALL_OK=false fi done if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then echo " [OK] Service file: ${SERVICE_NAME}.service" else echo " [FAIL] Service file not created" ALL_OK=false fi if [ -f "$CONFIG_FILE" ]; then if jq empty "$CONFIG_FILE" 2>/dev/null; then echo " [OK] Config: config.json (valid JSON)" else echo " [FAIL] Config: config.json (invalid JSON)" ALL_OK=false fi else echo " [FAIL] Config file missing" ALL_OK=false fi if [ -f "$SCHEDULE_FILE" ]; then echo " [OK] Schedule: schedule.txt" else echo " [FAIL] Schedule file missing" ALL_OK=false fi if command -v java >/dev/null 2>&1; then echo " [OK] Java: $(java -version 2>&1 | head -1)" else echo " [FAIL] Java not available" ALL_OK=false fi if [ -x /usr/local/bin/jruby ] && /usr/local/bin/jruby -v >/dev/null 2>&1; then echo " [OK] JRuby: $(/usr/local/bin/jruby -v 2>/dev/null | head -1)" else echo " [FAIL] Integrated JRuby (/usr/local/bin/jruby) not runnable" ALL_OK=false fi echo "" if [ "$ALL_OK" = true ]; then echo "=== Installation Complete ===" else echo "=== Installation Finished With Errors ===" echo "Review the [FAIL] items above before starting the service." fi echo "" echo "Next steps:" echo " 1. Place your music in $INSTALL_DIR/music/" echo " 2. Edit $SCHEDULE_FILE with your show/stream schedule" echo " 3. Review/edit $INSTALL_DIR/station.liq" echo " 4. Test: sudo systemctl start icecast2 && sudo systemctl start ${SERVICE_NAME}" echo " 5. Check logs: tail -f $INSTALL_DIR/logs/liquidsoap.log" echo "" echo "Manage shows (run as the liquidsoap user):" RB_PREFIX="sudo -u liquidsoap env GEM_HOME=$GEM_HOME_LOCAL GEM_PATH=$GEM_HOME_LOCAL /usr/local/bin/jruby -S bundle exec" echo " List: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --list-shows --detail" echo " Delete: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --delete-show " echo " Import OPML: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --import-opml " echo " Add show: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --add-show " echo " JSON state: $RB_PREFIX $INSTALL_DIR/update_playlists.rb --json"