mirror of
https://github.com/mistergibson/radio.git
synced 2026-09-08 22:09:51 -07:00
bug fixes
This commit is contained in:
parent
21f1b14109
commit
1c4fc11b47
7 changed files with 1133 additions and 1383 deletions
1177
fetch_podcasts.rb
1177
fetch_podcasts.rb
File diff suppressed because it is too large
Load diff
|
|
@ -1,293 +1,151 @@
|
||||||
#!/usr/bin/env bash
|
#!/bin/bash
|
||||||
#
|
# install_for_jruby - Installer for the JRuby radio automation stack.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
|
# Derives its service name from the directory it lives in, e.g.
|
||||||
|
# /srv/radio/install_for_jruby -> radio.service
|
||||||
|
# Prompts for a storage root and writes a complete config.json.
|
||||||
|
# Creates media subdirectories (music, podcasts, jingles, announcements),
|
||||||
|
# state/ and logs/ under the storage root, drops and recreates all
|
||||||
|
# database files, installs gems one at a time, generates a systemd unit,
|
||||||
|
# and adds cron entries to the existing liquidsoap user's crontab.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
echo "ERROR: this installer must be run as root." >&2
|
SERVICE_BASENAME="$(basename "${INSTALL_DIR}")"
|
||||||
|
SERVICE_NAME="${SERVICE_BASENAME}.service"
|
||||||
|
UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}"
|
||||||
|
|
||||||
|
CONFIG_JSON="${INSTALL_DIR}/config.json"
|
||||||
|
GEMS_DIR="${INSTALL_DIR}/.gems"
|
||||||
|
JRUBY_BIN=""
|
||||||
|
|
||||||
|
echo "=== ${SERVICE_BASENAME} installer ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# --- Detect JRuby -----------------------------------------------------------
|
||||||
|
if command -v jruby >/dev/null 2>&1; then
|
||||||
|
JRUBY_BIN="$(command -v jruby)"
|
||||||
|
elif [ -x /opt/jruby/bin/jruby ]; then
|
||||||
|
JRUBY_BIN="/opt/jruby/bin/jruby"
|
||||||
|
else
|
||||||
|
echo "ERROR: jruby not found on PATH or at /opt/jruby/bin/jruby." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
echo "==> Using JRuby: ${JRUBY_BIN}"
|
||||||
|
echo
|
||||||
|
|
||||||
INSTALL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
# --- Prompt for storage root ------------------------------------------------
|
||||||
SERVICE_NAME="$(basename "$INSTALL_DIR")"
|
STORAGE_ROOT_DEFAULT="/mnt/storage/radio"
|
||||||
UNIT_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
printf "Storage root [%s]: " "${STORAGE_ROOT_DEFAULT}"
|
||||||
LIQUIDSOAP_USER="liquidsoap"
|
read -r STORAGE_ROOT_INPUT
|
||||||
JRUBY_HOME_PINNED="/opt/jruby"
|
STORAGE_ROOT="${STORAGE_ROOT_INPUT:-${STORAGE_ROOT_DEFAULT}}"
|
||||||
JRUBY_VERSION="10.1.1.0"
|
echo "==> Storage root: ${STORAGE_ROOT}"
|
||||||
GEMS_DIR="${INSTALL_DIR}/.gems"
|
|
||||||
|
|
||||||
echo "==> Installing ${SERVICE_NAME} (JRuby stack) from ${INSTALL_DIR}"
|
# --- Create media and state directories -------------------------------------
|
||||||
|
for d in music podcasts jingles announcements state logs; do
|
||||||
|
mkdir -p "${STORAGE_ROOT}/${d}"
|
||||||
|
done
|
||||||
|
chown -R liquidsoap:liquidsoap "${STORAGE_ROOT}" 2>/dev/null || true
|
||||||
|
echo "==> Media/state/log directories created under ${STORAGE_ROOT}"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# --- Drop and recreate databases --------------------------------------------
|
||||||
# 1. Base packages
|
rm -f "${STORAGE_ROOT}/state/subscriptions.db" \
|
||||||
# ---------------------------------------------------------------------------
|
"${STORAGE_ROOT}/state/played.db" \
|
||||||
echo "==> Ensuring base packages..."
|
"${STORAGE_ROOT}/state/subscriptions.db-wal" \
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
"${STORAGE_ROOT}/state/subscriptions.db-shm" \
|
||||||
apt-get update -qq
|
"${STORAGE_ROOT}/state/played.db-wal" \
|
||||||
apt-get install -y -qq \
|
"${STORAGE_ROOT}/state/played.db-shm"
|
||||||
liquidsoap icecast2 jq curl unzip ca-certificates >/dev/null
|
echo "==> Databases dropped (will be recreated by scripts on first run)"
|
||||||
|
|
||||||
if ! id -u "$LIQUIDSOAP_USER" >/dev/null 2>&1; then
|
# --- Install gems one at a time ---------------------------------------------
|
||||||
useradd --system --create-home --shell /usr/sbin/nologin "$LIQUIDSOAP_USER"
|
export GEM_HOME="${GEMS_DIR}"
|
||||||
echo " created system user '${LIQUIDSOAP_USER}'"
|
export GEM_PATH="${GEMS_DIR}"
|
||||||
fi
|
mkdir -p "${GEMS_DIR}"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
for gem_name in sequel jdbc-sqlite3 json nokogiri; do
|
||||||
# 2. Java detection / install
|
echo "==> Installing gem: ${gem_name}"
|
||||||
# ---------------------------------------------------------------------------
|
if ! "${JRUBY_BIN}" -S gem install --local "${gem_name}" 2>/dev/null; then
|
||||||
have_java() {
|
"${JRUBY_BIN}" -S gem install "${gem_name}"
|
||||||
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
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# --- Write complete config.json ----------------------------------------------
|
||||||
# 5. Gems (one at a time, raised heap). Order matters: jdbc-sqlite3 first so
|
ICECAST_HOST_DEFAULT="192.168.0.200"
|
||||||
# it's present when sequel loads its JDBC SQLite subadapter.
|
ICECAST_PORT_DEFAULT="7777"
|
||||||
# ---------------------------------------------------------------------------
|
ICECAST_MOUNT_DEFAULT="/data"
|
||||||
export GEM_HOME="$GEMS_DIR"
|
ICECAST_SOURCE_USER_DEFAULT="source"
|
||||||
export GEM_PATH="$GEMS_DIR"
|
GPODDER_ENABLE_DEFAULT="false"
|
||||||
export JRUBY_OPTS="-J-Xmx1g -J-Xss512k"
|
GPODDER_HOST_DEFAULT="gpodder.net"
|
||||||
|
|
||||||
echo "==> Installing gems one at a time into ${GEMS_DIR} ..."
|
printf "Icecast host [%s]: " "${ICECAST_HOST_DEFAULT}"
|
||||||
"$JRUBY_BIN" -S gem install --no-document jdbc-sqlite3
|
read -r ICECAST_HOST_INPUT; ICECAST_HOST="${ICECAST_HOST_INPUT:-${ICECAST_HOST_DEFAULT}}"
|
||||||
"$JRUBY_BIN" -S gem install --no-document sequel
|
printf "Icecast source port [%s]: " "${ICECAST_PORT_DEFAULT}"
|
||||||
"$JRUBY_BIN" -S gem install --no-document json
|
read -r ICECAST_PORT_INPUT; ICECAST_PORT="${ICECAST_PORT_INPUT:-${ICECAST_PORT_DEFAULT}}"
|
||||||
|
printf "Mount point [%s]: " "${ICECAST_MOUNT_DEFAULT}"
|
||||||
chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$GEMS_DIR"
|
read -r ICECAST_MOUNT_INPUT; ICECAST_MOUNT="${ICECAST_MOUNT_INPUT:-${ICECAST_MOUNT_DEFAULT}}"
|
||||||
|
printf "Source username [%s]: " "${ICECAST_SOURCE_USER_DEFAULT}"
|
||||||
# ---------------------------------------------------------------------------
|
read -r ICECAST_SRC_USER_INPUT; ICECAST_SRC_USER="${ICECAST_SRC_USER_INPUT:-${ICECAST_SOURCE_USER_DEFAULT}}"
|
||||||
# 6. Interactive configuration (defaults from existing config.json)
|
printf "Source password: "
|
||||||
# ---------------------------------------------------------------------------
|
read -rs ICECAST_SRC_PASS
|
||||||
CONFIG_JSON="${INSTALL_DIR}/config.json"
|
echo
|
||||||
|
printf "gPodder sync enabled? (y/n) [n]: "
|
||||||
prompt() {
|
read -r GP_SYNC_INPUT
|
||||||
local prompt_text="$1" default="${2:-}"
|
case "${GP_SYNC_INPUT,,}" in
|
||||||
local current=""
|
y|yes) GP_ENABLED=true ;;
|
||||||
if [[ -n "$default" ]]; then
|
*) GP_ENABLED=false ;;
|
||||||
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
|
esac
|
||||||
|
GP_HOST="${GPODDER_HOST_DEFAULT}"
|
||||||
|
GP_USERNAME=""
|
||||||
|
GP_PASSWORD=""
|
||||||
|
GP_DEVICE_ID=""
|
||||||
|
if [ "${GP_ENABLED}" = "true" ]; then
|
||||||
|
printf "gPodder username: "
|
||||||
|
read -r GP_USERNAME
|
||||||
|
printf "gPodder password: "
|
||||||
|
read -rs GP_PASSWORD
|
||||||
|
echo
|
||||||
|
printf "gPodder device ID (leave blank to generate): "
|
||||||
|
read -r GP_DEVICE_ID
|
||||||
|
if [ -z "${GP_DEVICE_ID}" ]; then
|
||||||
|
GP_DEVICE_ID="radio-$(date +%s)-$$"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
cat > "${CONFIG_JSON}" <<EOF
|
||||||
# 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}",
|
"storage": "${STORAGE_ROOT}",
|
||||||
"icecast": {
|
"icecast": {
|
||||||
"host": "${IC_HOST}",
|
"host": "${ICECAST_HOST}",
|
||||||
"port": ${IC_PORT},
|
"port": ${ICECAST_PORT},
|
||||||
"mount": "${IC_MOUNT}",
|
"mount": "${ICECAST_MOUNT}",
|
||||||
"username": "${IC_USER}",
|
"source_username": "${ICECAST_SRC_USER}",
|
||||||
"password": "${IC_PASS}"
|
"source_password": "${ICECAST_SRC_PASS}"
|
||||||
},
|
},
|
||||||
"gpodder": {
|
"gpodder": {
|
||||||
"enable": $( [[ "$GP_ENABLE" =~ ^([Tt][Rr][Uu][Ee]|1)$ ]] && echo true || echo false ),
|
"enable": ${GP_ENABLED},
|
||||||
"host": "${GP_HOST}",
|
"host": "${GP_HOST}",
|
||||||
"username": "${GP_USER}",
|
"username": "${GP_USERNAME}",
|
||||||
"password": "${GP_PASS}"
|
"password": "${GP_PASSWORD}",
|
||||||
|
"device_id": "${GP_DEVICE_ID}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$CONFIG_JSON"
|
chmod 600 "${CONFIG_JSON}"
|
||||||
chown "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$CONFIG_JSON"
|
echo "==> Wrote ${CONFIG_JSON}"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# --- Generate systemd unit ----------------------------------------------------
|
||||||
# 9. Initialize BOTH SQLite databases with the full current schema.
|
cat > "${UNIT_PATH}" <<EOF
|
||||||
# Uses raw CREATE TABLE IF NOT EXISTS via Database#execute - avoids the
|
|
||||||
# Sequel create_table DSL, which is unreliable under JRuby (instance_exec'd
|
|
||||||
# generator methods can resolve to nil). Works on CRuby and JDBC alike.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
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")
|
|
||||||
|
|
||||||
def tune(db)
|
|
||||||
db.execute("PRAGMA journal_mode=WAL;")
|
|
||||||
db.execute("PRAGMA busy_timeout=5000;")
|
|
||||||
end
|
|
||||||
|
|
||||||
SHOWS_SQL = <<~SQL
|
|
||||||
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,
|
|
||||||
media_class TEXT,
|
|
||||||
created_at TEXT
|
|
||||||
);
|
|
||||||
SQL
|
|
||||||
|
|
||||||
EPISODES_SQL = <<~SQL
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
SQL
|
|
||||||
|
|
||||||
INDEX_SQL = <<~SQL
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_episodes_show_played ON episodes (show_slug, played);
|
|
||||||
SQL
|
|
||||||
|
|
||||||
db = Sequel.connect("jdbc:sqlite:#{subs_path}")
|
|
||||||
tune(db)
|
|
||||||
db.execute(SHOWS_SQL)
|
|
||||||
db.disconnect
|
|
||||||
|
|
||||||
db = Sequel.connect("jdbc:sqlite:#{played_path}")
|
|
||||||
tune(db)
|
|
||||||
db.execute(EPISODES_SQL)
|
|
||||||
db.execute(INDEX_SQL)
|
|
||||||
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]
|
[Unit]
|
||||||
Description=Radio automation (${SERVICE_NAME}) - liquidsoap
|
Description=${SERVICE_BASENAME} (JRuby radio automation)
|
||||||
After=network-online.target icecast2.service
|
After=network-online.target sound.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=${LIQUIDSOAP_USER}
|
User=liquidsoap
|
||||||
WorkingDirectory=${INSTALL_DIR}
|
WorkingDirectory=${INSTALL_DIR}
|
||||||
Environment=GEM_HOME=${GEMS_DIR}
|
Environment=GEM_HOME=${GEMS_DIR}
|
||||||
Environment=GEM_PATH=${GEMS_DIR}
|
Environment=GEM_PATH=${GEMS_DIR}
|
||||||
Environment=JRUBY_OPTS=-J-Xmx1g -J-Xss512k
|
|
||||||
ExecStart=${JRUBY_BIN} ${INSTALL_DIR}/station.rb
|
ExecStart=${JRUBY_BIN} ${INSTALL_DIR}/station.rb
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
@ -295,51 +153,25 @@ RestartSec=5
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
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"
|
|
||||||
|
|
||||||
chmod o+x "$INSTALL_DIR"
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
echo "==> Installed ${UNIT_PATH}"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# --- Cron entries (liquidsoap user's crontab already exists from package) -----
|
||||||
# 11. Cron jobs - installed into the liquidsoap user's crontab so all
|
CRON_FETCH="0 * * * * cd ${INSTALL_DIR} && ${INSTALL_DIR}/run_radio.sh fetch_podcasts.rb --fetch-all >> ${STORAGE_ROOT}/logs/fetch_cron.log 2>&1"
|
||||||
# database/media writes happen under the same identity as the service.
|
CRON_UPDATE="30 * * * * cd ${INSTALL_DIR} && ${INSTALL_DIR}/run_radio.sh update_playlist.rb >> ${STORAGE_ROOT}/logs/update_cron.log 2>&1"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
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"
|
|
||||||
|
|
||||||
sudo -u "$LIQUIDSOAP_USER" crontab -l 2>/dev/null | grep -vF "fetch_podcasts.rb" | grep -vF "update_playlists.rb" > /tmp/cron_ls_rb.$$ || true
|
CURRENT_CRONTAB=$(crontab -u liquidsoap -l 2>/dev/null || true)
|
||||||
echo "$FETCH_CRON" >> /tmp/cron_ls_rb.$$
|
NEW_CRONTAB="${CURRENT_CRONTAB}"
|
||||||
echo "$UPDATE_CRON" >> /tmp/cron_ls_rb.$$
|
if ! echo "${NEW_CRONTAB}" | grep -qF "fetch_podcasts.rb --fetch-all"; then
|
||||||
sudo -u "$LIQUIDSOAP_USER" crontab /tmp/cron_ls_rb.$$
|
NEW_CRONTAB="${NEW_CRONTAB}${NEW_CRONTAB:+$'\n'}${CRON_FETCH}"
|
||||||
rm -f /tmp/cron_ls_rb.$$
|
fi
|
||||||
|
if ! echo "${NEW_CRONTAB}" | grep -qF "update_playlist.rb"; then
|
||||||
|
NEW_CRONTAB="${NEW_CRONTAB}${NEW_CRONTAB:+$'\n'}${CRON_UPDATE}"
|
||||||
|
fi
|
||||||
|
echo "${NEW_CRONTAB}" | crontab -u liquidsoap -
|
||||||
|
echo "==> Cron entries installed for user liquidsoap"
|
||||||
|
|
||||||
crontab -l 2>/dev/null | grep -vF "fetch_podcasts.rb" | grep -vF "update_playlists.rb" > /tmp/cron_root_rb.$$ || true
|
echo
|
||||||
crontab /tmp/cron_root_rb.$$
|
echo "==> Done. Review ${CONFIG_JSON}, then:"
|
||||||
rm -f /tmp/cron_root_rb.$$
|
echo " systemctl start ${SERVICE_NAME}"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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 "============================================================"
|
|
||||||
|
|
|
||||||
|
|
@ -1,168 +1,152 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# install_for_python - Radio automation installer for Linux Mint 22.3
|
||||||
#
|
#
|
||||||
# install_for_python - Provision the Python radio automation stack.
|
# Installs the liquidsoap-based radio automation stack:
|
||||||
# Idempotent: safe to re-run. Derives the service name from this file's
|
# - Creates the 'liquidsoap' system user
|
||||||
# containing directory basename. Must be run as root.
|
# - Prompts for storage path, Icecast credentials, gpodder.net credentials
|
||||||
|
# - Generates config.json from the answers
|
||||||
|
# - Sets up directory structure under <storage>/
|
||||||
|
# - Ensures SQLite databases exist with correct schema
|
||||||
|
# - Installs JRuby gems (sequel, jdbc-sqlite3, nokogiri, json) one at a time
|
||||||
|
# - Writes the self-locating run_radio.sh launcher
|
||||||
|
# - Installs crontab entries (as liquidsoap) for periodic fetch + update
|
||||||
|
# - Optionally enables the systemd service
|
||||||
#
|
#
|
||||||
|
# Usage: sudo ./install_for_python
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Derive service name from containing directory name
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SERVICE_NAME="$(basename "$SCRIPT_DIR")"
|
||||||
|
SYSTEMD_UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
|
||||||
|
echo "=== ${SERVICE_NAME} Installer ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Root check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
echo "ERROR: this installer must be run as root." >&2
|
echo "ERROR: Please run as root (sudo)." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
# Create liquidsoap system user if missing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo "==> Ensuring base packages..."
|
if ! id -u liquidsoap >/dev/null 2>&1; then
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
echo "Creating system user 'liquidsoap'..."
|
||||||
apt-get update -qq
|
useradd --system --create-home --shell /usr/sbin/nologin liquidsoap
|
||||||
apt-get install -y -qq \
|
echo "Done."
|
||||||
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
|
else
|
||||||
read -rp "${prompt_text}: " current || true
|
echo "User 'liquidsoap' already exists."
|
||||||
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
|
fi
|
||||||
|
|
||||||
echo ""
|
# ---------------------------------------------------------------------------
|
||||||
echo "--- Storage ---"
|
# Prompt for configuration values
|
||||||
STORAGE_PATH=$(prompt "Storage path (media + state + logs)" "${existing_storage:-/mnt/storage/radio}")
|
# ---------------------------------------------------------------------------
|
||||||
[[ -z "$STORAGE_PATH" ]] && STORAGE_PATH="${existing_storage:-/mnt/storage/radio}"
|
echo
|
||||||
|
read -rp "Station data directory [/mnt/storage/radio]: " STORAGE
|
||||||
|
STORAGE="${STORAGE:-/mnt/storage/radio}"
|
||||||
|
|
||||||
echo ""
|
read -rp "Icecast host [192.168.0.200]: " ICECAST_HOST
|
||||||
echo "--- Icecast (source credentials) ---"
|
ICECAST_HOST="${ICECAST_HOST:-192.168.0.200}"
|
||||||
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 ""
|
read -rp "Icecast source port [7777]: " ICECAST_PORT
|
||||||
echo "--- gPodder sync (optional) ---"
|
ICECAST_PORT="${ICECAST_PORT:-7777}"
|
||||||
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 ""
|
read -rp "Icecast mount point [/data]: " ICECAST_MOUNT
|
||||||
echo "Summary:"
|
ICECAST_MOUNT="${ICECAST_MOUNT:-/data}"
|
||||||
echo " Storage : $STORAGE_PATH"
|
|
||||||
echo " Icecast : $IC_USER@$IC_HOST:$IC_PORT mount=$IC_MOUNT"
|
read -rp "Icecast source username [source]: " SOURCE_USER
|
||||||
echo " gPodder : enabled=$GP_ENABLE ($GP_USER @ $GP_HOST)"
|
SOURCE_USER="${SOURCE_USER:-source}"
|
||||||
read -rp "Proceed? [y/N]: " confirm || true
|
|
||||||
case "$confirm" in
|
read -rsp "Icecast source password: " SOURCE_PASS
|
||||||
[Yy]*) ;;
|
echo
|
||||||
*) echo "Aborted."; exit 0 ;;
|
|
||||||
esac
|
GPODDER_ENABLE=""
|
||||||
|
until [[ "$GPODDER_ENABLE" =~ ^[YyNn]$ ]]; do
|
||||||
|
read -rp "Enable gPodder.net sync? [y/N]: " GPODDER_ENABLE
|
||||||
|
done
|
||||||
|
GPODDER_ENABLED=false
|
||||||
|
[[ "$GPODDER_ENABLE" =~ ^[Yy]$ ]] && GPODDER_ENABLED=true
|
||||||
|
|
||||||
|
GPODDER_HOST="gpodder.net"
|
||||||
|
GPODDER_USER=""
|
||||||
|
GPODDER_PASS=""
|
||||||
|
if [[ "$GPODDER_ENABLED" == true ]]; then
|
||||||
|
read -rp "gPodder.net host [gpodder.net]: " GPODDER_HOST_IN
|
||||||
|
GPODDER_HOST="${GPODDER_HOST_IN:-gpodder.net}"
|
||||||
|
read -rp "gPodder.net username: " GPODDER_USER
|
||||||
|
read -rsp "gPodder.net password: " GPODDER_PASS
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 3. Create the directory tree
|
# Create directory structure
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo "==> Creating directory tree under $STORAGE_PATH ..."
|
echo
|
||||||
mkdir -p "$STORAGE_PATH"/{music,podcasts,jingles,announcements,state,playlists,logs}
|
echo "Creating directory structure under ${STORAGE} ..."
|
||||||
chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$STORAGE_PATH"
|
mkdir -p "${STORAGE}"/{state,podcasts,music,jingles,announcements,playlists,logs}
|
||||||
|
chown -R liquidsoap:liquidsoap "${STORAGE}"
|
||||||
|
chmod -R u+rwX,g+rwX,o+rX "${STORAGE}"
|
||||||
|
echo "Done."
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 4. Write config.json (mode 600)
|
# Generate config.json
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo "==> Writing $CONFIG_JSON ..."
|
CONFIG_FILE="${SCRIPT_DIR}/config.json"
|
||||||
cat > "$CONFIG_JSON" <<EOF
|
cat > "$CONFIG_FILE" <<EOF
|
||||||
{
|
{
|
||||||
"storage": "${STORAGE_PATH}",
|
"storage": "${STORAGE}",
|
||||||
"icecast": {
|
"icecast": {
|
||||||
"host": "${IC_HOST}",
|
"host": "${ICECAST_HOST}",
|
||||||
"port": ${IC_PORT},
|
"port": ${ICECAST_PORT},
|
||||||
"mount": "${IC_MOUNT}",
|
"mount": "${ICECAST_MOUNT}",
|
||||||
"username": "${IC_USER}",
|
"source_username": "${SOURCE_USER}",
|
||||||
"password": "${IC_PASS}"
|
"source_password": "${SOURCE_PASS}"
|
||||||
},
|
},
|
||||||
"gpodder": {
|
"gpodder": {
|
||||||
"enable": $( [[ "$GP_ENABLE" =~ ^([Tt][Rr][Uu][Ee]|1)$ ]] && echo true || echo false ),
|
"enable": ${GPODDER_ENABLED},
|
||||||
"host": "${GP_HOST}",
|
"host": "https://${GPODDER_HOST}",
|
||||||
"username": "${GP_USER}",
|
"username": "${GPODDER_USER}",
|
||||||
"password": "${GP_PASS}"
|
"password": "${GPODDER_PASS}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$CONFIG_JSON"
|
chown liquidsoap:liquidsoap "$CONFIG_FILE"
|
||||||
chown "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$CONFIG_JSON"
|
chmod 640 "$CONFIG_FILE"
|
||||||
|
echo "Wrote ${CONFIG_FILE}"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 5. Initialize BOTH SQLite databases with the full current schema.
|
# Ensure SQLite databases exist with correct schema
|
||||||
# This guarantees a known-good baseline at install time, independent of
|
|
||||||
# which script runs first. Idempotent via IF NOT EXISTS.
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo "==> Initializing SQLite databases ..."
|
STATE_DIR="${STORAGE}/state"
|
||||||
python3 - "$STORAGE_PATH/state" <<'PYINIT'
|
SUBS_DB="${STATE_DIR}/subscriptions.db"
|
||||||
import sqlite3, sys, os
|
PLAYED_DB="${STATE_DIR}/played.db"
|
||||||
state_dir = sys.argv[1]
|
|
||||||
|
|
||||||
subs = os.path.join(state_dir, "subscriptions.db")
|
ensure_schema() {
|
||||||
played = os.path.join(state_dir, "played.db")
|
local db="$1" table_sql="$2" index_sql="$3"
|
||||||
|
sqlite3 "$db" <<<"$table_sql"
|
||||||
|
[[ -n "$index_sql" ]] && sqlite3 "$db" <<<"$index_sql"
|
||||||
|
sqlite3 "$db" "PRAGMA journal_mode=WAL;"
|
||||||
|
}
|
||||||
|
|
||||||
conn = sqlite3.connect(subs)
|
SHOWS_SQL='CREATE TABLE IF NOT EXISTS shows (
|
||||||
conn.executescript("""
|
|
||||||
CREATE TABLE IF NOT EXISTS shows (
|
|
||||||
slug TEXT PRIMARY KEY,
|
slug TEXT PRIMARY KEY,
|
||||||
guid TEXT NOT NULL UNIQUE,
|
guid TEXT NOT NULL UNIQUE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
feed_url TEXT NOT NULL UNIQUE,
|
feed_url TEXT NOT NULL UNIQUE,
|
||||||
source TEXT DEFAULT 'manual',
|
source TEXT DEFAULT '"'"'manual'"'"',
|
||||||
opml_import INTEGER DEFAULT 0,
|
opml_import INTEGER DEFAULT 0,
|
||||||
archived INTEGER DEFAULT 1,
|
archived INTEGER DEFAULT 1,
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
media_class TEXT,
|
||||||
);
|
created_at TEXT
|
||||||
""")
|
);'
|
||||||
conn.commit(); conn.close()
|
|
||||||
|
|
||||||
conn = sqlite3.connect(played)
|
EPISODES_SQL='CREATE TABLE IF NOT EXISTS episodes (
|
||||||
conn.executescript("""
|
|
||||||
CREATE TABLE IF NOT EXISTS episodes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
show_slug TEXT NOT NULL,
|
show_slug TEXT NOT NULL,
|
||||||
guid TEXT NOT NULL,
|
guid TEXT NOT NULL,
|
||||||
|
|
@ -173,98 +157,157 @@ CREATE TABLE IF NOT EXISTS episodes (
|
||||||
played INTEGER DEFAULT 0,
|
played INTEGER DEFAULT 0,
|
||||||
played_at TEXT,
|
played_at TEXT,
|
||||||
UNIQUE (show_slug, guid)
|
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.")
|
INDEX_EPISODES_SQL='CREATE INDEX IF NOT EXISTS idx_episodes_show_played ON episodes (show_slug, played);'
|
||||||
PYINIT
|
|
||||||
chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$STORAGE_PATH/state"
|
echo
|
||||||
|
echo "Initializing SQLite databases..."
|
||||||
|
ensure_schema "$SUBS_DB" "$SHOWS_SQL" ""
|
||||||
|
ensure_schema "$PLAYED_DB" "$EPISODES_SQL" "$INDEX_EPISODES_SQL"
|
||||||
|
chown liquidsoap:liquidsoap "$SUBS_DB" "$PLAYED_DB"
|
||||||
|
echo "Done."
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 6. Python virtualenv + dependencies
|
# Install JRuby runtime dependencies (one gem per command to bound memory)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
VENV="${INSTALL_DIR}/.venv"
|
JRUBY_BIN="/opt/jruby/bin/jruby"
|
||||||
if [[ ! -x "${VENV}/bin/python" ]]; then
|
GEM_BIN="/opt/jruby/bin/gem"
|
||||||
echo "==> Creating Python venv at ${VENV} ..."
|
GEMS_HOME="${SCRIPT_DIR}/.gems"
|
||||||
python3 -m venv "$VENV"
|
|
||||||
|
if [[ -x "$JRUBY_BIN" ]]; then
|
||||||
|
echo
|
||||||
|
echo "Installing JRuby gem dependencies into ${GEMS_HOME} ..."
|
||||||
|
mkdir -p "$GEMS_HOME"
|
||||||
|
chown liquidsoap:liquidsoap "$GEMS_HOME"
|
||||||
|
|
||||||
|
install_gem() {
|
||||||
|
local gem_name="$1"
|
||||||
|
shift
|
||||||
|
local extra_args=("$@")
|
||||||
|
echo " -> gem install ${gem_name} ${extra_args[*]}"
|
||||||
|
sudo -u liquidsoap bash -c "
|
||||||
|
export GEM_HOME=${GEMS_HOME}
|
||||||
|
export GEM_PATH=${GEMS_HOME}:/opt/jruby/lib/ruby/gems/shared
|
||||||
|
${GEM_BIN} install ${gem_name} ${extra_args[*]} --no-document
|
||||||
|
" || echo " WARNING: failed to install ${gem_name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Core persistence
|
||||||
|
install_gem "sequel"
|
||||||
|
install_gem "jdbc-sqlite3"
|
||||||
|
|
||||||
|
# XML parsing. Pure-Java build under JRuby (--platform java) bundles
|
||||||
|
# Xerces/NekoHTML/Xalan as JARs; no libxml2-dev or compiler required.
|
||||||
|
install_gem "nokogiri" "--platform" "java"
|
||||||
|
|
||||||
|
# Convenience JSON (usually stdlib, but ensure availability)
|
||||||
|
install_gem "json"
|
||||||
|
|
||||||
|
echo "JRUBY DEPENDENCIES COMPLETE"
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
echo "WARNING: ${JRUBY_BIN} not found; skipping JRuby gem installation."
|
||||||
|
echo " Install JRuby manually, then re-run this installer or run:"
|
||||||
|
echo " sudo -u liquidsoap ${GEM_BIN} install sequel jdbc-sqlite3 json"
|
||||||
|
echo " sudo -u liquidsoap ${GEM_BIN} install nokogiri --platform java"
|
||||||
fi
|
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)
|
# Self-locating launcher (sets GEM_HOME/GEM_PATH before JRuby boots)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo "==> Writing systemd unit ${UNIT_FILE} ..."
|
LAUNCHER="${SCRIPT_DIR}/run_radio.sh"
|
||||||
cat > "$UNIT_FILE" <<EOF
|
cat > "$LAUNCHER" <<'LAUNCH_EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# run_radio.sh - self-locating JRuby launcher for the radio automation scripts.
|
||||||
|
# Exports GEM_HOME/GEM_PATH BEFORE jruby boots, because mutating them inside a
|
||||||
|
# running JRuby process does not reliably affect gem resolution (JRuby #5269).
|
||||||
|
#
|
||||||
|
# Usage: ./run_radio.sh <script.rb> [args...]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
GEMS_HOME="${SELF_DIR}/.gems"
|
||||||
|
JRUBY_HOME="/opt/jruby"
|
||||||
|
|
||||||
|
export GEM_HOME="${GEMS_HOME}"
|
||||||
|
export GEM_PATH="${GEMS_HOME}:${JRUBY_HOME}/lib/ruby/gems/shared"
|
||||||
|
|
||||||
|
exec "${JRUBY_HOME}/bin/jruby" "${SELF_DIR}/$@"
|
||||||
|
LAUNCH_EOF
|
||||||
|
chmod +x "$LAUNCHER"
|
||||||
|
chown liquidsoap:liquidsoap "$LAUNCHER"
|
||||||
|
echo "Wrote ${LAUNCHER}"
|
||||||
|
|
||||||
|
# Make the Ruby scripts executable
|
||||||
|
for rb in "${SCRIPT_DIR}"/*.rb; do
|
||||||
|
[[ -f "$rb" ]] && chmod +x "$rb" && chown liquidsoap:liquidsoap "$rb"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Crontab entries (installed into liquidsoap's crontab, not root's)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
CRON_FETCH="*/30 * * * * cd ${SCRIPT_DIR} && ./run_radio.sh fetch_podcasts.rb >> ${STORAGE}/logs/cron_fetch.log 2>&1"
|
||||||
|
CRON_UPDATE="15 * * * * cd ${SCRIPT_DIR} && ./run_radio.sh update_playlists.rb >> ${STORAGE}/logs/cron_update.log 2>&1"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Installing crontab entries for user 'liquidsoap'..."
|
||||||
|
( crontab -l -u liquidsoap 2>/dev/null || true ) | grep -vF "run_radio.sh" | \
|
||||||
|
{ cat; echo "$CRON_FETCH"; echo "$CRON_UPDATE"; } | crontab -u liquidsoap -
|
||||||
|
echo "Crontab updated."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Optional systemd service
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ENABLE_SERVICE=""
|
||||||
|
until [[ "$ENABLE_SERVICE" =~ ^[YyNn]$ ]]; do
|
||||||
|
read -rp "Enable ${SERVICE_NAME} systemd service now? [y/N]: " ENABLE_SERVICE
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$ENABLE_SERVICE" =~ ^[Yy]$ ]]; then
|
||||||
|
echo
|
||||||
|
echo "Writing ${SYSTEMD_UNIT} ..."
|
||||||
|
cat > "$SYSTEMD_UNIT" <<UNIT_EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Radio automation (${SERVICE_NAME}) - liquidsoap
|
Description=${SERVICE_NAME} radio automation (liquidsoap)
|
||||||
After=network-online.target icecast2.service
|
After=network-online.target icecast2.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=${LIQUIDSOAP_USER}
|
User=liquidsoap
|
||||||
WorkingDirectory=${INSTALL_DIR}
|
WorkingDirectory=${SCRIPT_DIR}
|
||||||
ExecStart=${INSTALL_DIR}/.venv/bin/python ${INSTALL_DIR}/station_runner.py
|
ExecStart=${SCRIPT_DIR}/station.liq
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=10
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
UNIT_EOF
|
||||||
|
|
||||||
# Minimal runner that execs liquidsoap on station.liq from the install dir.
|
|
||||||
cat > "${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
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now "${SERVICE_NAME}"
|
||||||
|
echo "Service ${SERVICE_NAME} enabled and started."
|
||||||
|
else
|
||||||
|
echo "Skipping systemd service (you can enable it later with:"
|
||||||
|
echo " sudo cp ${SCRIPT_DIR}/${SERVICE_NAME}.service /etc/systemd/system/ && sudo systemctl enable --now ${SERVICE_NAME})"
|
||||||
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 8. Cron jobs — installed into the liquidsoap user's crontab so all
|
# Summary
|
||||||
# database/media writes happen under the same identity as the service.
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo "==> Setting up cron jobs under user '${LIQUIDSOAP_USER}' ..."
|
echo
|
||||||
FETCH_CRON="0 * * * * cd ${INSTALL_DIR} && ./.venv/bin/python fetch_podcasts.py >> ${STORAGE_PATH}/logs/fetch.log 2>&1"
|
echo "=============================================="
|
||||||
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 " Installation complete."
|
||||||
echo ""
|
echo "----------------------------------------------"
|
||||||
echo " Next steps:"
|
echo " Storage: ${STORAGE}"
|
||||||
echo " 1. Drop background music into ${STORAGE_PATH}/music/"
|
echo " Config: ${CONFIG_FILE}"
|
||||||
echo " 2. Edit ${INSTALL_DIR}/schedule.txt for scheduled shows"
|
echo " Launcher: ${LAUNCHER}"
|
||||||
echo " 3. Review ${INSTALL_DIR}/station.liq"
|
echo " Service: ${SERVICE_NAME}"
|
||||||
echo " 4. Start: systemctl start icecast2 ${SERVICE_NAME}"
|
echo "----------------------------------------------"
|
||||||
echo "============================================================"
|
echo " Quick start:"
|
||||||
|
echo " cd ${SCRIPT_DIR}"
|
||||||
|
echo " sudo -u liquidsoap ./run_radio.sh fetch_podcasts.rb --list-shows"
|
||||||
|
echo " sudo -u liquidsoap ./run_radio.sh fetch_podcasts.rb --add-show <rss-url>"
|
||||||
|
echo " sudo -u liquidsoap ./run_radio.sh update_playlists.rb"
|
||||||
|
echo "=============================================="
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,5 @@
|
||||||
# them up natively at boot (in-process mutation is unreliable per JRuby #5269).
|
# them up natively at boot (in-process mutation is unreliable per JRuby #5269).
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
export GEM_HOME="${GEM_HOME:-$SCRIPT_DIR/.gems}"
|
export GEM_HOME="${GEM_HOME:-$SCRIPT_DIR/.gems}"
|
||||||
export GEM_PATH="${GEM_PATH:-$SCRIPT_DIR/.gems:/opt/jruby/lib/ruby/gems/shared}"
|
export GEM_PATH="${GEM_PATH:-$SCRIPT_DIR/.gems}"
|
||||||
exec /opt/jruby/bin/jruby "$@"
|
exec /usr/bin/jruby "$@"
|
||||||
|
|
|
||||||
43
run_radio.sh
43
run_radio.sh
|
|
@ -1,28 +1,33 @@
|
||||||
#!/usr/bin/env bash
|
#!/bin/bash
|
||||||
# run_radio.sh - Launcher for the radio automation JRuby scripts.
|
# run_radio.sh - Self-locating launcher for the JRuby radio automation scripts.
|
||||||
#
|
#
|
||||||
# Sets GEM_HOME/GEM_PATH in the environment BEFORE jruby boots, because
|
# Why a launcher: mutating GEM_HOME/GEM_PATH inside a running JRuby process does
|
||||||
# mutating those variables inside a running JRuby process does not reliably
|
# not reliably affect gem resolution (JRuby #5269). Exporting them BEFORE jruby
|
||||||
# affect gem resolution (JRuby issue #5269). Setting them pre-boot lets
|
# boots is the reliable path. This script derives its own location, points the
|
||||||
# RubyGems pick them up natively. Self-locating, so the whole tree can be
|
# gem env at the co-located .gems dir, passes a bounded JVM heap, and execs
|
||||||
# relocated without editing anything.
|
# jruby with the requested script + args. The .rb scripts keep a portable
|
||||||
|
# "#!/usr/bin/env jruby" shebang and are invoked THROUGH this launcher.
|
||||||
#
|
#
|
||||||
# Usage: ./run_radio.sh <script.rb> [args...]
|
# Heap sizing: feed parsing is streamed to disk (constant memory), but episode
|
||||||
# e.g. ./run_radio.sh fetch_podcasts.rb --list-shows
|
# downloads buffer per-file, so we give the JVM a comfortable-but-capped ceiling.
|
||||||
|
# NOTE: this JRuby build ignores JRUBY_OPTS, so JVM flags must be passed on the
|
||||||
|
# command line via -J (e.g. -J-Xmx2g), not through an environment variable.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./run_radio.sh <script.rb> [args...]
|
||||||
|
# sudo -u liquidsoap ./run_radio.sh fetch_podcasts.rb --add-show <url>
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
GEMS_DIR="${SCRIPT_DIR}/.gems"
|
GEMS_DIR="${SCRIPT_DIR}/.gems"
|
||||||
|
JRUBY_BIN="/usr/bin/jruby"
|
||||||
|
|
||||||
export GEM_HOME="${GEM_HOME:-${GEMS_DIR}}"
|
export GEM_HOME="${GEMS_DIR}"
|
||||||
# Prepend our gems dir; keep JRuby's shared path so stdlib stays reachable.
|
export GEM_PATH="${GEMS_DIR}"
|
||||||
_JRUBY_SHARED="/opt/jruby/lib/ruby/gems/shared"
|
|
||||||
if [[ -n "${GEM_PATH:-}" ]]; then
|
if [[ $# -lt 1 ]]; then
|
||||||
export GEM_PATH="${GEMS_DIR}:${GEM_PATH}"
|
echo "Usage: $(basename "$0") <script.rb> [args...]" >&2
|
||||||
else
|
exit 1
|
||||||
export GEM_PATH="${GEMS_DIR}:${_JRUBY_SHARED}"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec /opt/jruby/bin/jruby "$@"
|
exec "${JRUBY_BIN}" -J-Xms256m -J-Xmx2g "${SCRIPT_DIR}/$@"
|
||||||
|
|
||||||
|
|
|
||||||
0
station.rb
Normal file → Executable file
0
station.rb
Normal file → Executable file
|
|
@ -1,265 +1,218 @@
|
||||||
#!/usr/bin/env jruby
|
#!/usr/bin/env jruby
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
#
|
#
|
||||||
# update_playlists.rb - Select the next unplayed episode per show and write an
|
# update_playlists.rb - Select the next unplayed episode per show and write
|
||||||
# annotated URI queue file for station.liq to consume (JRuby/Sequel variant).
|
# annotated-URI queue files for station.liq to consume.
|
||||||
#
|
#
|
||||||
# Shares the same exclusive lockfile as fetch_podcasts.rb; skips cleanly if
|
# Reads from played.db (episode records with played flag), writes queue files
|
||||||
# the fetcher holds it. Databases run in WAL mode with a busy timeout.
|
# under <storage>/playlists/. Cycles archived shows when all episodes are
|
||||||
|
# already played.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./update_playlists.rb [--json]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --json Emit a JSON summary of each show's episode counts to stdout.
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Gem path bootstrap: pin GEM_HOME/GEM_PATH before any require so the script
|
|
||||||
# finds its gems regardless of how it was launched (sudo strips them by
|
|
||||||
# default via env_reset). Derived from this file's own location so the
|
|
||||||
# scripts are relocatable. We PREPEND our .gems dir to the existing GEM_PATH
|
|
||||||
# rather than replacing it, so JRuby's shared/stdlib path stays reachable.
|
|
||||||
# Guards prevent overriding an explicit environment.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
SCRIPT_DIR = File.expand_path(File.dirname(__FILE__))
|
|
||||||
GEMS_DIR = File.join(SCRIPT_DIR, ".gems")
|
|
||||||
ENV["GEM_HOME"] = GEMS_DIR unless ENV["GEM_HOME"]
|
|
||||||
_existing_gem_path = ENV["GEM_PATH"].to_s.split(":").reject(&:empty?)
|
|
||||||
ENV["GEM_PATH"] = ([GEMS_DIR] + _existing_gem_path).uniq.join(":")
|
|
||||||
Gem.paths = { "GEM_HOME" => ENV["GEM_HOME"], "GEM_PATH" => ENV["GEM_PATH"] }
|
|
||||||
|
|
||||||
require "sequel"
|
|
||||||
require "json"
|
require "json"
|
||||||
require "logger"
|
require "sequel"
|
||||||
require "time"
|
|
||||||
|
|
||||||
ROOT = SCRIPT_DIR
|
SCRIPT_DIR = File.expand_path(File.dirname(__FILE__))
|
||||||
CONFIG_PATH = File.join(ROOT, "config.json")
|
CONFIG_PATH = File.join(SCRIPT_DIR, "config.json")
|
||||||
|
|
||||||
$state_dir = nil
|
|
||||||
$subs_db_path = nil
|
|
||||||
$played_db_path = nil
|
|
||||||
$podcasts_dir = nil
|
|
||||||
$logs_dir = nil
|
|
||||||
$playlists_dir = nil
|
|
||||||
$lock_file = nil
|
|
||||||
|
|
||||||
def load_config
|
def load_config
|
||||||
JSON.parse(File.read(CONFIG_PATH))
|
raw = File.read(CONFIG_PATH)
|
||||||
|
cfg = JSON.parse(raw)
|
||||||
|
raise "FATAL: storage missing from config.json" unless cfg["storage"] && !cfg["storage"].to_s.empty?
|
||||||
|
cfg
|
||||||
end
|
end
|
||||||
|
|
||||||
def init_paths!
|
CFG = load_config
|
||||||
cfg = load_config
|
STORAGE_ROOT = CFG["storage"]
|
||||||
storage = File.realpath(cfg["storage"])
|
STATE_DIR = File.join(STORAGE_ROOT, "state")
|
||||||
$state_dir = File.join(storage, "state")
|
PLAYLISTS_DIR = File.join(STORAGE_ROOT, "playlists")
|
||||||
$subs_db_path = File.join($state_dir, "subscriptions.db")
|
LOG_DIR = File.join(STATE_DIR, "logs")
|
||||||
$played_db_path = File.join($state_dir, "played.db")
|
LOCK_FILE = File.join(STATE_DIR, "radio.lock")
|
||||||
$podcasts_dir = File.join(storage, "podcasts")
|
SUBS_DB_PATH = File.join(STATE_DIR, "subscriptions.db")
|
||||||
$logs_dir = File.join(storage, "logs")
|
EPISODES_DB_PATH = File.join(STATE_DIR, "episodes.db")
|
||||||
$playlists_dir = File.join(storage, "playlists")
|
|
||||||
$lock_file = File.join($state_dir, "radio.lock")
|
[DIRS_TO_CREATE].each do |d|
|
||||||
|
Dir.mkdir(d) unless Dir.exist?(d)
|
||||||
end
|
end
|
||||||
|
|
||||||
$log = Logger.new(STDOUT)
|
$log_fh = File.open(File.join(LOG_DIR, "update_playlists.log"), "a")
|
||||||
$log.formatter = proc { |msg, _severity, _time, _progname| "#{Time.now} [INFO] #{msg}\n" }
|
def log(level, msg)
|
||||||
|
ts = Time.now.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
def log_error(msg)
|
line = "[#{ts}] [#{level}] #{msg}"
|
||||||
$log.error(msg)
|
puts(line)
|
||||||
|
$log_fh.write("#{line}\n")
|
||||||
|
$log_fh.flush
|
||||||
end
|
end
|
||||||
|
|
||||||
def setup_logging!
|
db_subs = Sequel.jdbc("sqlite:", SUBS_DB_PATH)
|
||||||
$log.instance_variable_set(:@logdev,
|
db_eps = Sequel.jdbc("sqlite:", EPISODES_DB_PATH)
|
||||||
Logger::LogDevice.new([STDOUT, File.join($logs_dir, "update.log")]))
|
|
||||||
|
db_subs.execute("PRAGMA journal_mode=WAL;")
|
||||||
|
db_eps.execute("PRAGMA journal_mode=WAL;")
|
||||||
|
db_subs.execute("PRAGMA busy_timeout=5000;")
|
||||||
|
db_eps.execute("PRAGMA busy_timeout=5000;")
|
||||||
|
|
||||||
|
def table_exists?(db, name)
|
||||||
|
db[:sqlite_master].where(type: "table", name: name).count > 0
|
||||||
end
|
end
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def ensure_schema(db, path, label)
|
||||||
# Schema: single source of truth, applied idempotently via raw SQL through
|
if !table_exists?(db, "shows")
|
||||||
# Database#execute. Raw DDL avoids the Sequel create_table/alter_table DSL,
|
db.execute <<-SQL
|
||||||
# which is unreliable under JRuby (instance_exec'd generator methods can
|
|
||||||
# resolve to nil). Works identically on CRuby and JDBC.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
SHOWS_SQL = <<~SQL.freeze
|
|
||||||
CREATE TABLE IF NOT EXISTS shows (
|
CREATE TABLE IF NOT EXISTS shows (
|
||||||
slug TEXT PRIMARY KEY,
|
guid TEXT PRIMARY KEY,
|
||||||
guid TEXT NOT NULL UNIQUE,
|
slug TEXT UNIQUE NOT NULL,
|
||||||
name TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
feed_url TEXT NOT NULL UNIQUE,
|
feed_url TEXT NOT NULL,
|
||||||
source TEXT DEFAULT 'manual',
|
audio_only INTEGER DEFAULT 1,
|
||||||
opml_import INTEGER DEFAULT 0,
|
archive INTEGER DEFAULT 0,
|
||||||
archived INTEGER DEFAULT 1,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
media_class TEXT,
|
)
|
||||||
created_at TEXT
|
|
||||||
);
|
|
||||||
SQL
|
SQL
|
||||||
|
log("INFO", "Created 'shows' table in #{label}")
|
||||||
|
end
|
||||||
|
|
||||||
EPISODES_SQL = <<~SQL.freeze
|
if !table_exists?(db, "episodes")
|
||||||
|
db.execute <<-SQL
|
||||||
CREATE TABLE IF NOT EXISTS episodes (
|
CREATE TABLE IF NOT EXISTS episodes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
guid TEXT PRIMARY KEY,
|
||||||
show_slug TEXT NOT NULL,
|
show_guid TEXT NOT NULL REFERENCES shows(guid),
|
||||||
guid TEXT NOT NULL,
|
|
||||||
title TEXT,
|
title TEXT,
|
||||||
file_path TEXT,
|
|
||||||
enclosure_url TEXT,
|
enclosure_url TEXT,
|
||||||
runlength INTEGER,
|
runlength INTEGER DEFAULT 0,
|
||||||
played INTEGER DEFAULT 0,
|
played INTEGER DEFAULT 0,
|
||||||
played_at TEXT,
|
downloaded INTEGER DEFAULT 0,
|
||||||
UNIQUE (show_slug, guid)
|
local_path TEXT,
|
||||||
);
|
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
SQL
|
SQL
|
||||||
|
log("INFO", "Created 'episodes' table in #{label}")
|
||||||
INDEX_EPISODES_SQL = <<~SQL.freeze
|
end
|
||||||
CREATE INDEX IF NOT EXISTS idx_episodes_show_played ON episodes (show_slug, played);
|
|
||||||
SQL
|
|
||||||
|
|
||||||
def tune(db)
|
|
||||||
# Plain-SQL pragmas via Database#execute, which works on CRuby and JRuby/JDBC
|
|
||||||
# across all modern Sequel versions (the :pragma extension is CRuby-only and
|
|
||||||
# db.sql requires Sequel >= 5.42).
|
|
||||||
db.execute("PRAGMA journal_mode=WAL;")
|
|
||||||
db.execute("PRAGMA busy_timeout=5000;")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def connect_subs
|
ensure_schema(db_subs, SUBS_DB_PATH, "subscriptions.db")
|
||||||
db = Sequel.connect("jdbc:sqlite:#{$subs_db_path}")
|
ensure_schema(db_eps, EPISODES_DB_PATH, "episodes.db")
|
||||||
tune(db)
|
|
||||||
db.execute(SHOWS_SQL)
|
|
||||||
db
|
|
||||||
end
|
|
||||||
|
|
||||||
def connect_played
|
|
||||||
db = Sequel.connect("jdbc:sqlite:#{$played_db_path}")
|
|
||||||
tune(db)
|
|
||||||
db.execute(EPISODES_SQL)
|
|
||||||
db.execute(INDEX_EPISODES_SQL)
|
|
||||||
db
|
|
||||||
end
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Mutual exclusion via flock on a shared lockfile.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
$lock_fh = nil
|
|
||||||
|
|
||||||
def acquire_lock!
|
def acquire_lock!
|
||||||
Dir.mkdir($state_dir) unless Dir.exist?($state_dir)
|
@lock_fh = File.new(LOCK_FILE, "a+")
|
||||||
fh = File.open($lock_file, File::RDWR | File::CREAT, 0o644)
|
|
||||||
begin
|
begin
|
||||||
fh.flock(File::LOCK_EX | File::LOCK_NB)
|
@lock_fh.flock(File::LOCK_EX | File::LOCK_NB)
|
||||||
rescue Errno::EACCES, Errno::EAGAIN
|
rescue IOError
|
||||||
fh.close
|
log("WARN", "Another radio process holds the lock; skipping this run.")
|
||||||
return false
|
exit 0
|
||||||
end
|
end
|
||||||
fh.truncate(0)
|
|
||||||
fh.write(Process.pid.to_s)
|
|
||||||
fh.rewind
|
|
||||||
$lock_fh = fh
|
|
||||||
true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def release_lock!
|
def release_lock!
|
||||||
return if $lock_fh.nil?
|
@lock_fh.flock(File::LOCK_UN) if @lock_fh
|
||||||
begin
|
@lock_fh.close if @lock_fh
|
||||||
$lock_fh.flock(File::LOCK_UN)
|
@lock_fh = nil
|
||||||
$lock_fh.close
|
end
|
||||||
ensure
|
|
||||||
$lock_fh = nil
|
def make_annotated_uri(ep)
|
||||||
|
uri = ep[:local_path] || ep[:enclosure_url]
|
||||||
|
rl = ep[:runlength].to_i
|
||||||
|
ttl = ep[:title].to_s.gsub('"', '\\"')
|
||||||
|
"annotate:liq_runlength=\"#{rl}\",liq_title=\"#{ttl}\":#{uri}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def select_next_episode(show_guid)
|
||||||
|
# Try to find an unplayed episode
|
||||||
|
ep = db_eps[:episodes].where(show_guid: show_guid, played: 0).order(:fetched_at.asc).first
|
||||||
|
|
||||||
|
if ep.nil?
|
||||||
|
# All episodes played — reset all to unplayed, then pick the first
|
||||||
|
count = db_eps[:episodes].where(show_guid: show_guid).count
|
||||||
|
if count > 0
|
||||||
|
db_eps[:episodes].where(show_guid: show_guid).update(played: 0)
|
||||||
|
log("INFO", "Reset all episodes for show #{show_guid} to played=0 (cycling)")
|
||||||
|
ep = db_eps[:episodes].where(show_guid: show_guid).order(:fetched_at.asc).first
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
ep
|
||||||
# Queue-file generation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
def annotate_uri(runlength, title, uri)
|
|
||||||
def esc(v)
|
|
||||||
'"' + v.to_s.gsub('"', '\\"') + '"'
|
|
||||||
end
|
|
||||||
"annotate:liq_runlength=#{esc(runlength)},liq_title=#{esc(title)}:" + uri
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def select_next_episode(slug, played_db)
|
def update_show_queue(show)
|
||||||
played_db[:episodes]
|
slug = show[:slug]
|
||||||
.where(show_slug: slug, played: 0)
|
queue_dir = File.join(PLAYLISTS_DIR, slug)
|
||||||
.order(:id.asc)
|
Dir.mkdir(queue_dir) unless Dir.exist?(queue_dir)
|
||||||
.limit(1)
|
|
||||||
.first
|
ep = select_next_episode(show[:guid])
|
||||||
|
|
||||||
|
if ep.nil?
|
||||||
|
log("WARN", "No episodes available for show '#{slug}'")
|
||||||
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
def mark_as_played(slug, guid, played_db)
|
annotated = make_annotated_uri(ep)
|
||||||
played_db[:episodes]
|
queue_file = File.join(queue_dir, "next.uri")
|
||||||
.where(show_slug: slug, guid: guid)
|
File.write(queue_file, "#{annotated}\n")
|
||||||
.update(played: 1, played_at: Time.now.utc.strftime("%Y-%m-%d %H:%M:%S"))
|
|
||||||
end
|
|
||||||
|
|
||||||
def write_queue_file(slug, ep, archived)
|
# Mark as played
|
||||||
uri = archived ? ep[:file_path] : ep[:enclosure_url]
|
db_eps[:episodes].where(guid: ep[:guid]).update(played: 1)
|
||||||
return nil if uri.nil? || uri.empty?
|
|
||||||
line = annotate_uri(ep[:runlength], ep[:title], uri)
|
log("INFO", "Queued episode '#{ep[:title]}' for show '#{slug}' (runlength=#{ep[:runlength]}s)")
|
||||||
out = File.join($playlists_dir, "#{slug}.txt")
|
true
|
||||||
File.write(out, line + "\n")
|
|
||||||
out
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_all
|
def update_all
|
||||||
subs_db = connect_subs
|
shows = db_subs[:shows].all
|
||||||
played_db = connect_played
|
|
||||||
shows = subs_db[:shows].order(:name).all
|
|
||||||
updated = 0
|
|
||||||
shows.each do |show|
|
|
||||||
slug = show[:slug]
|
|
||||||
archived = show[:archived] == 1
|
|
||||||
ep = select_next_episode(slug, played_db)
|
|
||||||
next if ep.nil?
|
|
||||||
out = write_queue_file(slug, ep, archived)
|
|
||||||
if out.nil?
|
|
||||||
$log.warn("No playable URI for #{show[:name]} (#{slug}); skipping.")
|
|
||||||
next
|
|
||||||
end
|
|
||||||
mark_as_played(slug, ep[:guid], played_db)
|
|
||||||
updated += 1
|
|
||||||
$log.info("Queued #{slug}: #{ep[:title]} -> #{File.basename(out)}")
|
|
||||||
end
|
|
||||||
subs_db.disconnect
|
|
||||||
played_db.disconnect
|
|
||||||
$log.info("=== Update complete: #{updated} show(s) queued ===")
|
|
||||||
end
|
|
||||||
|
|
||||||
def json_summary
|
if shows.empty?
|
||||||
subs_db = connect_subs
|
log("INFO", "No shows registered; nothing to do.")
|
||||||
played_db = connect_played
|
|
||||||
shows = subs_db[:shows].order(:name).all
|
|
||||||
result = {}
|
|
||||||
shows.each do |show|
|
|
||||||
slug = show[:slug]
|
|
||||||
total = played_db[:episodes].where(show_slug: slug).count
|
|
||||||
unplayed = played_db[:episodes].where(show_slug: slug, played: 0).count
|
|
||||||
result[slug] = {
|
|
||||||
name: show[:name],
|
|
||||||
total: total,
|
|
||||||
unplayed: unplayed,
|
|
||||||
played: total - unplayed
|
|
||||||
}
|
|
||||||
end
|
|
||||||
subs_db.disconnect
|
|
||||||
played_db.disconnect
|
|
||||||
puts JSON.pretty_generate(result)
|
|
||||||
end
|
|
||||||
|
|
||||||
def main
|
|
||||||
args = ARGV.dup
|
|
||||||
json_mode = args.delete("--json")
|
|
||||||
|
|
||||||
init_paths!
|
|
||||||
[$state_dir, $logs_dir, $playlists_dir].each do |dir|
|
|
||||||
Dir.mkdir(dir) unless Dir.exist?(dir)
|
|
||||||
end
|
|
||||||
setup_logging!
|
|
||||||
|
|
||||||
if json_mode
|
|
||||||
json_summary
|
|
||||||
else
|
|
||||||
if !acquire_lock!
|
|
||||||
$log.info("Another radio process holds the lock; skipping this run.")
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
queued_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
shows.each do |show|
|
||||||
|
begin
|
||||||
|
if update_show_queue(show)
|
||||||
|
queued_count += 1
|
||||||
|
else
|
||||||
|
failed_count += 1
|
||||||
|
end
|
||||||
|
rescue Exception => e
|
||||||
|
failed_count += 1
|
||||||
|
log("ERROR", "Failed to update queue for show '#{show[:slug]}': #{e.class} #{e.message}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log("INFO", "Update complete: #{queued_count} queued, #{failed_count} failed out of #{shows.size} shows")
|
||||||
|
|
||||||
|
if ARGV.include?("--json")
|
||||||
|
summary = {}
|
||||||
|
shows.each do |show|
|
||||||
|
total = db_eps[:episodes].where(show_guid: show[:guid]).count
|
||||||
|
played = db_eps[:episodes].where(show_guid: show[:guid], played: 1).count
|
||||||
|
unplayed = total - played
|
||||||
|
summary[show[:slug]] = { total: total, played: played, unplayed: unplayed }
|
||||||
|
end
|
||||||
|
puts(JSON.pretty_generate(summary))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
acquire_lock!
|
||||||
begin
|
begin
|
||||||
update_all
|
update_all
|
||||||
ensure
|
ensure
|
||||||
release_lock!
|
release_lock!
|
||||||
end
|
end
|
||||||
end
|
rescue Exception => e
|
||||||
|
log("ERROR", "Fatal error: #{e.class} #{e.message}")
|
||||||
|
log("ERROR", e.backtrace.first(10).join("\n"))
|
||||||
|
exit 1
|
||||||
end
|
end
|
||||||
|
|
||||||
main if __FILE__ == $PROGRAM_NAME
|
db_subs.disconnect
|
||||||
|
db_eps.disconnect
|
||||||
|
$log_fh.close
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue