#!/bin/bash # # install_for_python - Setup for liquidsoap radio automation stack # Target: Linux Mint 22.3 (Ubuntu 24.04 Noble based) # # Usage: sudo ./install_for_python # Must be run from within the target directory (e.g., /srv/audio/) # 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" echo "=== Radio Automation Installer ===" 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" # Load existing config as defaults if present 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. System packages # ------------------------------------------------------- echo "" echo "--- Installing system packages ---" apt-get update apt-get install -y \ liquidsoap \ icecast2 \ jq \ python3 \ python3-pip \ python3-venv \ ffmpeg \ lame \ libtag1-dev \ curl \ ca-certificates # ------------------------------------------------------- # 3. Python virtual environment # ------------------------------------------------------- echo "--- Setting up Python virtualenv ---" VENV_DIR="$INSTALL_DIR/.venv" python3 -m venv "$VENV_DIR" "$VENV_DIR/bin/pip" install --upgrade pip "$VENV_DIR/bin/pip" install \ feedparser \ requests \ mutagen PYTHON_BIN="$VENV_DIR/bin/python3" # ------------------------------------------------------- # 4. 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 # ------------------------------------------------------- # 5. 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" # ------------------------------------------------------- # 6. 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 # ------------------------------------------------------- # 7. Verify required project files exist # ------------------------------------------------------- echo "--- Verifying project files ---" PROJECT_FILES=("station.liq" "fetch_podcasts.py" "update_playlists.py") 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 # ------------------------------------------------------- # 8. 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} 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 # ------------------------------------------------------- # 9. Create cron jobs (hourly fetch + hourly playlist update) # ------------------------------------------------------- echo "--- Setting up cron jobs ---" FETCH_CRON="0 * * * * cd $INSTALL_DIR && $PYTHON_BIN fetch_podcasts.py >> $INSTALL_DIR/logs/fetch.log 2>&1" UPDATE_CRON="30 * * * * cd $INSTALL_DIR && $PYTHON_BIN update_playlists.py >> $INSTALL_DIR/logs/update.log 2>&1" # Pull existing crontab (empty if none) EXISTING_CRON=$(crontab -l -u "$LIQUIDSOAP_USER" 2>/dev/null || true) # Remove any stale entries for these scripts to avoid duplicates on re-runs CLEANED_CRON=$(echo "$EXISTING_CRON" | grep -v "fetch_podcasts.py\|update_playlists.py" || true) # Add fetch job if not present if ! echo "$EXISTING_CRON" | grep -q "fetch_podcasts.py"; then CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON }$FETCH_CRON" echo " Added: fetch_podcasts.py (hourly)" else echo " Skipped: fetch_podcasts.py job already exists" fi # Add update job if not present if ! echo "$EXISTING_CRON" | grep -q "update_playlists.py"; then CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON }$UPDATE_CRON" echo " Added: update_playlists.py (hourly at :30)" else echo " Skipped: update_playlists.py job already exists" fi # Write back the combined crontab echo "$CLEANED_CRON" | crontab -u "$LIQUIDSOAP_USER" - # ------------------------------------------------------- # 10. 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 # ------------------------------------------------------- # 11. 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 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:" echo " List: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --list-shows --detail" echo " Delete: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --delete-show " echo " Import OPML: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --import-opml " echo " Add show: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --add-show " echo " JSON state: $PYTHON_BIN $INSTALL_DIR/update_playlists.py --json"