radio/install_for_jruby
2026-09-01 21:03:09 -07:00

341 lines
13 KiB
Shell
Executable file

#!/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). Order matters: jdbc-sqlite3 first so
# it's present when sequel loads its JDBC SQLite subadapter.
# ---------------------------------------------------------------------------
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 sequel
"$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" <<EOF
{
"storage": "${STORAGE_PATH}",
"icecast": {
"host": "${IC_HOST}",
"port": ${IC_PORT},
"mount": "${IC_MOUNT}",
"username": "${IC_USER}",
"password": "${IC_PASS}"
},
"gpodder": {
"enable": $( [[ "$GP_ENABLE" =~ ^([Tt][Rr][Uu][Ee]|1)$ ]] && echo true || echo false ),
"host": "${GP_HOST}",
"username": "${GP_USER}",
"password": "${GP_PASS}"
}
}
EOF
chmod 600 "$CONFIG_JSON"
chown "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$CONFIG_JSON"
# ---------------------------------------------------------------------------
# 9. Initialize BOTH SQLite databases with the full current schema.
# Uses Sequel over jdbc-sqlite3, matching how the Ruby scripts access the
# DB at runtime. Idempotent via IF NOT EXISTS semantics.
# ---------------------------------------------------------------------------
echo "==> Initializing SQLite databases via Sequel/jdbc-sqlite3 ..."
GEM_HOME="$GEMS_DIR" GEM_PATH="$GEMS_DIR" "$JRUBY_BIN" -e '
require "sequel"
state_dir = ARGV[0]
subs_path = File.join(state_dir, "subscriptions.db")
played_path = File.join(state_dir, "played.db")
db = Sequel.connect("jdbc:sqlite:#{subs_path}")
db.extension :pragma
db.pragma journal_mode: :wal
db.pragma busy_timeout: 5000
unless db.table_exists?(:shows)
db.create_table(:shows) do |t|
t.primary_key :slug, type: :string
t.string :guid, null: false, unique: true
t.string :name, null: false
t.string :feed_url, null: false, unique: true
t.string :source, default: "manual"
t.integer :opml_import, default: 0
t.integer :archived, default: 1
t.string :media_class
t.string :created_at
end
end
db.disconnect
db = Sequel.connect("jdbc:sqlite:#{played_path}")
db.extension :pragma
db.pragma journal_mode: :wal
db.pragma busy_timeout: 5000
unless db.table_exists?(:episodes)
db.create_table(:episodes) do |t|
t.primary_key :id
t.string :show_slug, null: false
t.string :guid, null: false
t.string :title
t.string :file_path
t.string :enclosure_url
t.integer :runlength
t.integer :played, default: 0
t.string :played_at
t.unique_constraint %i[show_slug guid]
end
end
unless db.index_exists?(:episodes, [:show_slug, :played])
db.create_index(:episodes, [:show_slug, :played], name: :idx_episodes_show_played)
end
db.disconnect
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" <<EOF
[Unit]
Description=Radio automation (${SERVICE_NAME}) - liquidsoap
After=network-online.target icecast2.service
Wants=network-online.target
[Service]
Type=simple
User=${LIQUIDSOAP_USER}
WorkingDirectory=${INSTALL_DIR}
Environment=GEM_HOME=${GEMS_DIR}
Environment=GEM_PATH=${GEMS_DIR}
Environment=JRUBY_OPTS=-J-Xmx1g -J-Xss512k
ExecStart=${JRUBY_BIN} ${INSTALL_DIR}/station.rb
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
# Minimal Ruby runner that execs liquidsoap on station.liq from the install dir.
cat > "${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 "============================================================"