storage dir setup

This commit is contained in:
G. Gibson 2026-08-29 15:58:25 -07:00
commit 1b191b868d
9 changed files with 418 additions and 402 deletions

35
.gitignore vendored
View file

@ -1,37 +1,20 @@
# Local Ruby gems (installed by install_for_jruby) # Secrets and local configuration
.gems/ config.json
# Python virtualenv # Runtime data (should live under the storage path, not here)
.venv/
# Runtime state: SQLite DBs and journals
state/ state/
*.db
*.db-journal
*.sqlite3
*.sqlite3-journal
# Logs
logs/ logs/
playlists/
*.db
*.log *.log
# Large / regenerable media directories # Downloaded media (belongs under the storage path)
music/
podcasts/ podcasts/
music/
jingles/ jingles/
announcements/ announcements/
# Generated playlist files # OS cruft
playlists/
*.pls
# Secrets
config.json
# OS / editor noise
.DS_Store .DS_Store
Thumbs.db Thumbs.db
*.swp
*~
.idea/
.vscode/

107
README.md
View file

@ -1,5 +1,112 @@
[![Hippocratic License HL3-FULL](https://img.shields.io/static/v1?label=Hippocratic%20License&message=HL3-FULL&labelColor=5e2751&color=bc8c3d)](https://firstdonoharm.dev/version/3/0/full.html) [![Hippocratic License HL3-FULL](https://img.shields.io/static/v1?label=Hippocratic%20License&message=HL3-FULL&labelColor=5e2751&color=bc8c3d)](https://firstdonoharm.dev/version/3/0/full.html)
# Radio Automation
Self-hosted internet radio built on **liquidsoap** feeding an **Icecast**
server, with podcast subscription management via gPodder.net or manual feeds.
Two parallel implementations are provided:
- **Python** (`fetch_podcasts.py`, `update_playlists.py`) — CPython 3, uses
`feedparser` and `requests`.
- **JRuby** (`fetch_podcasts.rb`, `update_playlists.rb`) — JRuby 9.x/10.x,
uses only stdlib plus the `sqlite3` gem.
Pick whichever you prefer; both produce identical behaviour and share the
same `config.json` and `station.liq`.
## Layout
Everything that grows over time (downloaded podcasts, music, jingles,
announcements, state databases, logs, generated playlists) lives under a
single **storage path** chosen at install time and recorded in
`config.json`. The code itself stays in this directory.
<storage>/
music/ # background music library (recursive mp3/m4a scan)
podcasts/<slug>/ # downloaded episodes, one folder per show
jingles/ # optional bumpers
announcements/ # optional station IDs
state/
subscriptions.db
played.db
playlists/ # generated per-show .pls files
logs/ # fetch.log, update.log, liquidsoap.log, cron.log
The install directory contains only code and configuration:
fetch_podcasts.py / .rb
update_playlists.py / .rb
station.liq
install_for_python.sh / install_for_jruby.sh
config.json # created by the installer (never committed)
schedule.txt # cron-style scheduled shows (you author this)
## Installing
Run the installer matching your runtime:
sudo ./install_for_python.sh
# or
sudo ./install_for_jruby.sh
You will be prompted for:
1. **Storage path** — where all media and state live. Defaults to
`/srv/radio-storage`. Choose a disk with plenty of free space; this is
what keeps downloads off your boot drive.
2. **Icecast** source port, mount point, username, and password.
3. Optionally, **gPodder.net** credentials for automatic subscription sync.
The installer creates the directory tree, writes `config.json` (mode 600),
installs the `liquidsoap` systemd unit named after this directory, and adds
two cron jobs:
- Hourly at minute 0: fetch new podcast episodes.
- Hourly at minute 30: regenerate per-show playlists from playback history.
Start the station with:
systemctl start <directory-name>
## Managing shows
List registered shows:
python3 fetch_podcasts.py --list-shows --detail
# or
jruby fetch_podcasts.rb --list-shows --detail
Add a show directly from a feed URL:
python3 fetch_podcasts.py --add-show https://example.com/feed.xml
Delete a show and its downloaded data:
python3 fetch_podcasts.py --delete-show some_slug
Import an OPML export (e.g. from gpodder):
python3 fetch_podcasts.py --import-opml ~/gpodder_export.opml
Shows imported via OPML are flagged and protected from pruning during
gPodder sync.
## Playback model
Background music plays continuously. Scheduled shows interrupt the music at
their appointed times; each scheduled entry in `schedule.txt` needs a
runlength so the scheduler knows when to hand control back to the music bed.
Episode runlengths are extracted from RSS enclosure data when available.
Playlists recursively scan nested directories for `.mp3` and `.m4a` files,
so you can organise your music however you like.
## Notes
- Gems for JRuby should be installed one at a time (separate `gem install`
commands); bundling several in one command can hit memory limits.
- `config.json` is gitignored and contains secrets — keep it mode 600.
# Radio Automation Stack # Radio Automation Stack
A self-hosted internet radio automation system for Linux Mint 22.3 (Ubuntu 24.04 Noble). It continuously plays background music, interrupts it with scheduled podcast shows and external streams, downloads new podcast episodes from RSS feeds, and manages playback history — all fed to an Icecast server via liquidsoap. A self-hosted internet radio automation system for Linux Mint 22.3 (Ubuntu 24.04 Noble). It continuously plays background music, interrupts it with scheduled podcast shows and external streams, downloads new podcast episodes from RSS feeds, and manages playback history — all fed to an Icecast server via liquidsoap.

View file

@ -2,6 +2,9 @@
""" """
fetch_podcasts.py - Podcast subscription management and episode fetching fetch_podcasts.py - Podcast subscription management and episode fetching
for the liquidsoap radio automation stack. for the liquidsoap radio automation stack.
All data (podcasts, state DBs, logs, playlists) lives under the storage path
defined in config.json ("storage" key), keeping the boot drive clean.
""" """
import argparse import argparse
@ -20,19 +23,36 @@ import requests
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
CONFIG_PATH = ROOT / "config.json" CONFIG_PATH = ROOT / "config.json"
STATE_DIR = ROOT / "state"
SUBS_DB = STATE_DIR / "subscriptions.db"
PLAYED_DB = STATE_DIR / "played.db"
PODCASTS_DIR = ROOT / "podcasts"
LOGS_DIR = ROOT / "logs"
AUDIO_EXTS = {".mp3", ".m4a"} AUDIO_EXTS = {".mp3", ".m4a"}
STATE_DIR = None
SUBS_DB = None
PLAYED_DB = None
PODCASTS_DIR = None
LOGS_DIR = None
PLAYLISTS_DIR = None
def load_config():
with open(CONFIG_PATH) as f:
return json.load(f)
def init_paths():
"""Resolve all data paths from config.json storage key."""
global STATE_DIR, SUBS_DB, PLAYED_DB, PODCASTS_DIR, LOGS_DIR, PLAYLISTS_DIR
cfg = load_config()
storage = Path(cfg["storage"]).expanduser().resolve()
STATE_DIR = storage / "state"
SUBS_DB = STATE_DIR / "subscriptions.db"
PLAYED_DB = STATE_DIR / "played.db"
PODCASTS_DIR = storage / "podcasts"
LOGS_DIR = storage / "logs"
PLAYLISTS_DIR = storage / "playlists"
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s", format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[ handlers=[
logging.FileHandler(LOGS_DIR / "fetch.log"),
logging.StreamHandler(sys.stdout), logging.StreamHandler(sys.stdout),
], ],
) )
@ -41,9 +61,11 @@ log = logging.getLogger("fetch_podcasts")
def log_error(msg): def log_error(msg):
log.error(msg) log.error(msg)
def load_config(): def _setup_logging():
with open(CONFIG_PATH) as f: """Add file handler once LOGS_DIR is known."""
return json.load(f) fh = logging.FileHandler(LOGS_DIR / "fetch.log")
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
log.addHandler(fh)
def open_subs_db(): def open_subs_db():
conn = sqlite3.connect(SUBS_DB) conn = sqlite3.connect(SUBS_DB)
@ -313,7 +335,7 @@ def remove_show_data(slug):
pod_dir = PODCASTS_DIR / slug pod_dir = PODCASTS_DIR / slug
if pod_dir.exists(): if pod_dir.exists():
shutil.rmtree(pod_dir) shutil.rmtree(pod_dir)
pls = ROOT / "playlists" / f"{slug}.pls" pls = PLAYLISTS_DIR / f"{slug}.pls"
if pls.exists(): if pls.exists():
pls.unlink() pls.unlink()
@ -373,9 +395,12 @@ def main():
parser.add_argument("--import-opml", metavar="FILE", help="Import shows from an OPML file") parser.add_argument("--import-opml", metavar="FILE", help="Import shows from an OPML file")
args = parser.parse_args() args = parser.parse_args()
STATE_DIR.mkdir(exist_ok=True) init_paths()
LOGS_DIR.mkdir(exist_ok=True) STATE_DIR.mkdir(parents=True, exist_ok=True)
PODCASTS_DIR.mkdir(exist_ok=True) LOGS_DIR.mkdir(parents=True, exist_ok=True)
PODCASTS_DIR.mkdir(parents=True, exist_ok=True)
PLAYLISTS_DIR.mkdir(parents=True, exist_ok=True)
_setup_logging()
config = load_config() config = load_config()

View file

@ -14,13 +14,29 @@ require "optparse"
module RadioAutomation module RadioAutomation
ROOT = File.expand_path("..", __dir__) ROOT = File.expand_path("..", __dir__)
CONFIG_PATH = File.join(ROOT, "config.json") CONFIG_PATH = File.join(ROOT, "config.json")
STATE_DIR = File.join(ROOT, "state")
SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
PLAYED_DB = File.join(STATE_DIR, "played.db")
PODCASTS_DIR = File.join(ROOT, "podcasts")
LOGS_DIR = File.join(ROOT, "logs")
AUDIO_EXTS = [".mp3", ".m4a"] AUDIO_EXTS = [".mp3", ".m4a"]
# Resolved at runtime from config.json "storage" key
STORAGE_DIR = nil
STATE_DIR = nil
SUBS_DB = nil
PLAYED_DB = nil
PODCASTS_DIR = nil
LOGS_DIR = nil
PLAYLISTS_DIR = nil
def self.init_paths
cfg = JSON.parse(File.read(CONFIG_PATH))
@storage = File.expand_path(cfg["storage"])
self.STORAGE_DIR = @storage
self.STATE_DIR = File.join(@storage, "state")
self.SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
self.PLAYED_DB = File.join(STATE_DIR, "played.db")
self.PODCASTS_DIR = File.join(@storage, "podcasts")
self.LOGS_DIR = File.join(@storage, "logs")
self.PLAYLISTS_DIR = File.join(@storage, "playlists")
end
def self.log_info(msg) def self.log_info(msg)
puts "#{Time.now.iso8601} [INFO] #{msg}" puts "#{Time.now.iso8601} [INFO] #{msg}"
append_log("fetch.log", msg) append_log("fetch.log", msg)
@ -47,9 +63,6 @@ module RadioAutomation
s[0, 60] || "show" s[0, 60] || "show"
end end
# ---------------------------------------------------------
# Database
# ---------------------------------------------------------
def self.open_subs_db def self.open_subs_db
db = SQLite3::Database.new(SUBS_DB) db = SQLite3::Database.new(SUBS_DB)
db.results_as_hash = true db.results_as_hash = true
@ -84,15 +97,11 @@ module RadioAutomation
db db
end end
# ---------------------------------------------------------
# gPodder.net sync
# ---------------------------------------------------------
def self.gpodder_sync(config) def self.gpodder_sync(config)
g = config["gpodder"] g = config["gpodder"]
base = g["host"].chomp("/") base = g["host"].chomp("/")
username = g["username"] username = g["username"]
password = g["password"] password = g["password"]
url = "#{base}/subscriptions/#{CGI.escape(username)}.opml" url = "#{base}/subscriptions/#{CGI.escape(username)}.opml"
puts "--- Syncing subscriptions from #{base} ---" puts "--- Syncing subscriptions from #{base} ---"
@ -184,11 +193,7 @@ module RadioAutomation
removed removed
end end
# ---------------------------------------------------------
# Feed parsing and download
# ---------------------------------------------------------
def self.extract_duration(entry_xml) def self.extract_duration(entry_xml)
# Try media:duration first
if (m = entry_xml.match(/media:duration[^>]*content="([^"]+)"/)) if (m = entry_xml.match(/media:duration[^>]*content="([^"]+)"/))
val = m[1] val = m[1]
return val.to_i if val =~ /\A\d+\z/ return val.to_i if val =~ /\A\d+\z/
@ -197,7 +202,6 @@ module RadioAutomation
return (h || 0) * 3600 + (mn || 0) * 60 + (s || 0) return (h || 0) * 3600 + (mn || 0) * 60 + (s || 0)
end end
end end
# Fall back to enclosure length (bytes) -> rough seconds at 128kbps
if (m = entry_xml.match(/enclosure[^>]*length="(\d+)"/)) if (m = entry_xml.match(/enclosure[^>]*length="(\d+)"/))
bytes = m[1].to_i bytes = m[1].to_i
return (bytes * 8 / 128_000) if bytes > 0 return (bytes * 8 / 128_000) if bytes > 0
@ -266,7 +270,6 @@ module RadioAutomation
subs_db = open_subs_db subs_db = open_subs_db
new_count = 0 new_count = 0
# Simple regex-based RSS/Atom entry extraction
raw.scan(/<(?:item|entry)[^>]*>(.*?)<\/(?:item|entry)>/mi).each do |(entry_xml)| raw.scan(/<(?:item|entry)[^>]*>(.*?)<\/(?:item|entry)>/mi).each do |(entry_xml)|
guid_m = entry_xml.match(/<guid[^>]*>([^<]*)<\/guid>|<id>([^<]*)<\/id>|<link[^>]*href="([^"]+)"/i) guid_m = entry_xml.match(/<guid[^>]*>([^<]*)<\/guid>|<id>([^<]*)<\/id>|<link[^>]*href="([^"]+)"/i)
guid = guid_m ? (guid_m[1] || guid_m[2] || guid_m[3]).strip : Digest::MD5.hexdigest(entry_xml[0, 200]) guid = guid_m ? (guid_m[1] || guid_m[2] || guid_m[3]).strip : Digest::MD5.hexdigest(entry_xml[0, 200])
@ -312,9 +315,6 @@ module RadioAutomation
log_info("=== Fetch complete: #{total_new} new episode(s) ===") log_info("=== Fetch complete: #{total_new} new episode(s) ===")
end end
# ---------------------------------------------------------
# Admin operations
# ---------------------------------------------------------
def self.list_shows(detail: false) def self.list_shows(detail: false)
db = open_subs_db db = open_subs_db
rows = db.query_all("SELECT slug, name, feed_url, source, opml_import FROM shows ORDER BY name") rows = db.query_all("SELECT slug, name, feed_url, source, opml_import FROM shows ORDER BY name")
@ -359,7 +359,7 @@ module RadioAutomation
def self.remove_show_data(slug) def self.remove_show_data(slug)
pod_dir = File.join(PODCASTS_DIR, slug) pod_dir = File.join(PODCASTS_DIR, slug)
FileUtils.rm_rf(pod_dir) if Dir.exist?(pod_dir) FileUtils.rm_rf(pod_dir) if Dir.exist?(pod_dir)
pls = File.join(ROOT, "playlists", "#{slug}.pls") pls = File.join(PLAYLISTS_DIR, "#{slug}.pls")
File.delete(pls) if File.exist?(pls) File.delete(pls) if File.exist?(pls)
end end
@ -394,9 +394,6 @@ module RadioAutomation
log_info("OPML import: #{shows.size} show(s) processed.") log_info("OPML import: #{shows.size} show(s) processed.")
end end
# ---------------------------------------------------------
# Main flow
# ---------------------------------------------------------
def self.run_fetch(config) def self.run_fetch(config)
g = config["gpodder"] g = config["gpodder"]
if g["enable"] == true if g["enable"] == true
@ -423,9 +420,11 @@ module RadioAutomation
opts.on("--import-opml FILE", "Import shows from an OPML file") { |v| options[:import] = v } opts.on("--import-opml FILE", "Import shows from an OPML file") { |v| options[:import] = v }
end.parse! end.parse!
init_paths
FileUtils.mkdir_p(STATE_DIR) FileUtils.mkdir_p(STATE_DIR)
FileUtils.mkdir_p(LOGS_DIR) FileUtils.mkdir_p(LOGS_DIR)
FileUtils.mkdir_p(PODCASTS_DIR) FileUtils.mkdir_p(PODCASTS_DIR)
FileUtils.mkdir_p(PLAYLISTS_DIR)
config = load_config config = load_config

View file

@ -1,200 +1,113 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# install_for_jruby - Provision the JRuby-based radio automation stack. #
# Detects/reuses existing Java and JRuby; installs gems one at a time. # install_for_jruby.sh - Installs the JRuby-based radio automation stack.
# Must be run as root from within the target directory (e.g. /srv/radio/). # Same layout as the Python installer but invokes jruby for the scripts.
# Idempotent: safe to re-run. # Derives its service name from the containing directory, prompts for a
# storage path, writes config.json, creates the systemd unit and cron jobs.
#
set -euo pipefail set -euo pipefail
INSTALL_DIR="$(pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE_NAME="$(basename "$INSTALL_DIR")" DIR_NAME="$(basename "$SCRIPT_DIR")"
GEM_HOME_LOCAL="$INSTALL_DIR/.gems" SERVICE_NAME="${DIR_NAME}"
JRBURY_VERSION="10.1.1.0" SYSTEMD_UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
MAVEN_BASE="https://repo1.maven.org/maven2/org/jruby/jruby-dist" CONFIG_FILE="${SCRIPT_DIR}/config.json"
JRUBY_HOME_PINNED="/opt/jruby-${JRBURY_VERSION}"
echo "=== Radio Automation Installer (JRuby) ===" JRuby_BIN="$(command -v jruby || echo /usr/local/bin/jruby)"
echo "Install dir: $INSTALL_DIR"
echo "Service name: $SERVICE_NAME"
echo
# --- Prompt for config values (pre-fill from existing config.json) --- echo "=== ${SERVICE_NAME} installer (JRuby) ==="
EXISTING_CONFIG="$INSTALL_DIR/config.json" echo "Install dir: ${SCRIPT_DIR}"
declare -A CFG echo "Using jruby: ${JRuby_BIN}"
CFG[ICE_HOST]="localhost" echo ""
CFG[ICE_PORT]="7777"
CFG[ICE_MOUNT]="/audio.mp3"
CFG[ICE_USER]="source"
CFG[ICE_PASS]=""
CFG[GPODDER_ENABLE]="false"
CFG[GPODDER_HOST]="https://gpodder.net"
CFG[GPODDER_USER]=""
CFG[GPODDER_PASS]=""
if [[ -f "$EXISTING_CONFIG" ]]; then # --- Prompt for storage path ---------------------------------------------
echo "Existing config.json found; using as defaults." DEFAULT_STORAGE="/srv/radio-storage"
CFG[ICE_HOST]=$(jq -r '.icecast.host // "localhost"' "$EXISTING_CONFIG") read -rp "Enter storage path for media/state/logs [${DEFAULT_STORAGE}]: " STORAGE_PATH
CFG[ICE_PORT]=$(jq -r '.icecast.port // 7777' "$EXISTING_CONFIG") STORAGE_PATH="${STORAGE_PATH:-$DEFAULT_STORAGE}"
CFG[ICE_MOUNT]=$(jq -r '.icecast.mount // "/audio.mp3"' "$EXISTING_CONFIG") mkdir -p "${STORAGE_PATH}"/{music,podcasts,jingles,announcements,state,playlists,logs}
CFG[ICE_USER]=$(jq -r '.icecast.username // "source"' "$EXISTING_CONFIG") chown -R liquidsoap:liquidsoap "${STORAGE_PATH}" 2>/dev/null || true
CFG[ICE_PASS]=$(jq -r '.icecast.password // ""' "$EXISTING_CONFIG")
CFG[GPODDER_ENABLE]=$(jq -r '.gpodder.enable // false' "$EXISTING_CONFIG")
CFG[GPODDER_HOST]=$(jq -r '.gpodder.host // "https://gpodder.net"' "$EXISTING_CONFIG")
CFG[GPODDER_USER]=$(jq -r '.gpodder.username // ""' "$EXISTING_CONFIG")
CFG[GPODDER_PASS]=$(jq -r '.gpodder.password // ""' "$EXISTING_CONFIG")
fi
read -rp "Icecast host [$CFG[ICE_HOST]]: " v; CFG[ICE_HOST]=${v:-$CFG[ICE_HOST]} # --- Collect Icecast credentials ------------------------------------------
read -rp "Icecast port [$CFG[ICE_PORT]]: " v; CFG[ICE_PORT]=${v:-$CFG[ICE_PORT]} read -rp "Icecast source port [8000]: " IC_PORT
read -rp "Icecast mount [$CFG[ICE_MOUNT]]: " v; CFG[ICE_MOUNT]=${v:-$CFG[ICE_MOUNT]} IC_PORT="${IC_PORT:-8000}"
read -rp "Icecast source username [$CFG[ICE_USER]]: " v; CFG[ICE_USER]=${v:-$CFG[ICE_USER]} read -rp "Icecast mount [/radio.mp3]: " IC_MOUNT
read -rsp "Icecast source password [$CFG[ICE_PASS]]: "; echo; CFG[ICE_PASS]=${v:-$CFG[ICE_PASS]} IC_MOUNT="${IC_MOUNT:-/radio.mp3}"
read -rp "Enable gPodder sync? (true/false) [$CFG[GPODDER_ENABLE]]: " v; CFG[GPODDER_ENABLE]=${v:-$CFG[GPODDER_ENABLE]} read -rp "Icecast source username [sourceadmin]: " IC_USER
read -rp "gPodder host [$CFG[GPODDER_HOST]]: " v; CFG[GPODDER_HOST]=${v:-$CFG[GPODDER_HOST]} IC_USER="${IC_USER:-sourceadmin}"
read -rp "gPodder username [$CFG[GPODDER_USER]]: " v; CFG[GPODDER_USER]=${v:-$CFG[GPODDER_USER]} read -rsp "Icecast source password: " IC_PASS; echo
read -rsp "gPodder password [$CFG[GPODDER_PASS]]: "; echo; CFG[GPODDER_PASS]=${v:-$CFG[GPODDER_PASS]} IC_PASS="${IC_PASS:?Password required}"
echo # --- gPodder sync (optional) ----------------------------------------------
echo "Summary:" read -rp "Enable gPodder.net sync? [y/N]: " GPODDER_ENABLE
echo " Icecast: ${CFG[ICE_HOST]}:${CFG[ICE_PORT]}${CFG[ICE_MOUNT]}" case "${GPODDER_ENABLE,,}" in
echo " gPodder sync: ${CFG[GPODDER_ENABLE]}" y|yes)
read -rp "Proceed? [y/N] " confirm read -rp "gPodder host [https://gpodder.net]: " GP_HOST
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } GP_HOST="${GP_HOST:-https://gpodder.net}"
read -rp "gPodder username: " GP_USER
read -rsp "gPodder password: " GP_PASS; echo
GP_JSON=$(jq -n --arg h "$GP_HOST" --arg u "$GP_USER" --arg p "$GP_PASS" \
'{enable:true, host:$h, username:$u, password:$p}')
;;
*)
GP_JSON='{"enable":false,"host":"","username":"","password":""}'
;;
esac
# --- Base packages --- # --- Write config.json -----------------------------------------------------
echo "Installing base packages..." cat > "$CONFIG_FILE" <<EOF
apt-get update -qq
apt-get install -y -qq liquidsoap icecast2 jq curl unzip ca-certificates >/dev/null
# --- Detect or install Java (>= 21) ---
JAVA_OK=false
if command -v java &>/dev/null; then
JAVA_VER=$(java -version 2>&1 | head -1 | sed 's/.*"\([0-9]*\)\..*/\1/')
if (( JAVA_VER >= 21 )); then
echo "Found Java $JAVA_VER; reusing."
JAVA_OK=true
fi
fi
if [[ "$JAVA_OK" != "true" ]]; then
echo "Installing OpenJDK 21 headless..."
apt-get install -y -qq openjdk-21-jdk-headless >/dev/null
fi
# --- Detect or install JRuby ---
JRUBY_BIN=""
if [[ -x "$JRUBY_HOME_PINNED/bin/jruby" ]]; then
JRUBY_BIN="$JRUBY_HOME_PINNED/bin/jruby"
echo "Found pinned JRuby at $JRUBY_HOME_PINNED"
elif command -v jruby &>/dev/null; then
JRUBY_BIN="$(command -v jruby)"
echo "Found JRuby on PATH: $JRUBY_BIN"
else
echo "Downloading JRuby ${JRBURY_VERSION} from Maven Central..."
TARBALL="jruby-dist-${JRBURY_VERSION}-bin.tar.gz"
URL="${MAVEN_BASE}/${JRBURY_VERSION}/${TARBALL}"
cd /tmp
curl -fsSL -o "$TARBALL" "$URL"
tar xzf "$TARBALL"
mv "jruby-${JRBURY_VERSION}" "$JRUBY_HOME_PINNED"
rm -f "$TARBALL"
JRUBY_BIN="$JRUBY_HOME_PINNED/bin/jruby"
echo "Installed JRuby to $JRUBY_HOME_PINNED"
fi
# --- Register via update-alternatives ---
for cmd in jruby bundle gem rake irb; do
ALT_SRC="$($JRUBY_BIN -e "puts Gem.bindir" 2>/dev/null || echo "$(${JRUBY_BIN%/jruby}${cmd}" --version &>/dev/null && which ${cmd} 2>/dev/null || echo "$JRUBY_HOME_PINNED/bin/${cmd}")")"
# Simpler: just link the known bin paths
LINK_TARGET="$JRUBY_HOME_PINNED/bin/${cmd}"
if [[ -x "$LINK_TARGET" ]]; then
ln -sf "$LINK_TARGET" "/usr/local/bin/${cmd}"
update-alternatives --install "/usr/local/bin/${cmd}" "${cmd}" "$LINK_TARGET" 100 || true
fi
done
echo "JRuby integrated via /usr/local/bin symlinks."
# --- Install gems one at a time (avoid OOM on low-RAM hosts) ---
export GEM_HOME="$GEM_HOME_LOCAL"
export GEM_PATH="$GEM_HOME_LOCAL"
export JRUBY_OPTS="-J-Xmx1g -J-Xss512k"
echo "Installing bundler..."
"$JRUBY_BIN" -S gem install bundler --no-document 2>&1 | tail -1
echo "Installing nokogiri..."
"$JRUBY_BIN" -S gem install nokogiri --no-document 2>&1 | tail -1
echo "Installing sqlite3..."
"$JRUBY_BIN" -S gem install sqlite3 --no-document 2>&1 | tail -1
echo "Installing json..."
"$JRUBY_BIN" -S gem install json --no-document 2>&1 | tail -1
# --- Create directory structure ---
for d in music podcasts jingles announcements playlists state logs; do
mkdir -p "$INSTALL_DIR/$d"
done
# --- Generate config.json ---
cat > "$INSTALL_DIR/config.json" <<EOF
{ {
"storage": "${STORAGE_PATH}",
"icecast": { "icecast": {
"host": "${CFG[ICE_HOST]}", "host": "127.0.0.1",
"port": ${CFG[ICE_PORT]}, "port": ${IC_PORT},
"mount": "${CFG[ICE_MOUNT]}", "mount": "${IC_MOUNT}",
"username": "${CFG[ICE_USER]}", "username": "${IC_USER}",
"password": "${CFG[ICE_PASS]}" "password": "${IC_PASS}"
}, },
"gpodder": { "gpodder": ${GP_JSON}
"enable": ${CFG[GPODDER_ENABLE]},
"host": "${CFG[GPODDER_HOST]}",
"username": "${CFG[GPODDER_USER]}",
"password": "${CFG[GPODDER_PASS]}"
}
} }
EOF EOF
chmod 600 "$INSTALL_DIR/config.json" chmod 600 "$CONFIG_FILE"
echo "Wrote ${CONFIG_FILE}"
# --- Ensure liquidsoap user exists --- # --- Gem check (install one at a time to avoid memory limits) --------------
if ! id liquidsoap &>/dev/null; then for gem in sqlite3 json; do
useradd --system --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin liquidsoap if ! "${JRuby_BIN}" -e "require '${gem}'" >/dev/null 2>&1; then
echo "Installing gem: ${gem}"
gem install "${gem}"
fi fi
chown -R liquidsoap:liquidsoap "$INSTALL_DIR" done
# --- Write systemd service --- # --- Systemd unit ------------------------------------------------------------
cat > /etc/systemd/system/"$SERVICE_NAME".service <<EOF cat > "$SYSTEMD_UNIT" <<EOF
[Unit] [Unit]
Description=Liquidsoap radio station ($SERVICE_NAME) Description=${SERVICE_NAME} radio automation (liquidsoap)
After=network.target icecast2.service After=network.target icecast2.service
[Service] [Service]
User=liquidsoap User=liquidsoap
WorkingDirectory=$INSTALL_DIR WorkingDirectory=${SCRIPT_DIR}
Environment=GEM_HOME=${GEM_HOME_LOCAL} ExecStart=/usr/bin/liquidsoap ${SCRIPT_DIR}/station.liq
Environment=GEM_PATH=${GEM_HOME_LOCAL}
Environment=JRUBY_OPTS=-J-Xmx1g -J-Xss512k
ExecStart=/usr/bin/liquidsoap $INSTALL_DIR/station.liq
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
# --- Set up cron jobs (deduplicated) ---
FETCH_CMD="sudo -u liquidsoap env GEM_HOME=${GEM_HOME_LOCAL} GEM_PATH=${GEM_HOME_LOCAL} JRUBY_OPTS=-J-Xmx1g -J-Xss512k /usr/local/bin/jruby -S bundle exec $INSTALL_DIR/fetch_podcasts.rb >> $INSTALL_DIR/logs/cron_fetch.log 2>&1"
UPDATE_CMD="sudo -u liquidsoap env GEM_HOME=${GEM_HOME_LOCAL} GEM_PATH=${GEM_HOME_LOCAL} JRUBY_OPTS=-J-Xmx1g -J-Xss512k /usr/local/bin/jruby -S bundle exec $INSTALL_DIR/update_playlists.rb >> $INSTALL_DIR/logs/cron_update.log 2>&1"
(crontab -l 2>/dev/null | grep -v "fetch_podcasts.rb"; echo "0 * * * * $FETCH_CMD") | crontab -
(crontab -l 2>/dev/null | grep -v "update_playlists.rb"; echo "30 * * * * $UPDATE_CMD") | crontab -
# --- Reload and enable ---
systemctl daemon-reload systemctl daemon-reload
systemctl enable "$SERVICE_NAME" systemctl enable "${SERVICE_NAME}"
echo "Enabled systemd unit: ${SERVICE_NAME}"
echo # --- Cron --------------------------------------------------------------------
CRON_FETCH="0 * * * * cd ${SCRIPT_DIR} && ${JRuby_BIN} fetch_podcasts.rb >> ${STORAGE_PATH}/logs/cron.log 2>&1"
CRON_PLAYLISTS="30 * * * * cd ${SCRIPT_DIR} && ${JRuby_BIN} update_playlists.rb >> ${STORAGE_PATH}/logs/cron.log 2>&1"
(crontab -l 2>/dev/null | grep -v "fetch_podcasts.rb\|update_playlists.rb"; echo "$CRON_FETCH"; echo "$CRON_PLAYLISTS") | crontab -
echo "Installed cron jobs:"
echo " $CRON_FETCH"
echo " $CRON_PLAYLISTS"
echo ""
echo "=== Installation complete ===" echo "=== Installation complete ==="
echo "Next steps:" echo "Storage root: ${STORAGE_PATH}"
echo " 1. Drop MP3/M4A files into $INSTALL_DIR/music/" echo "Start with: systemctl start ${SERVICE_NAME}"
echo " 2. Edit $INSTALL_DIR/schedule.txt with your show schedule"
echo " 3. Review $INSTALL_DIR/station.liq"
echo " 4. Start services:"
echo " sudo systemctl start icecast2"
echo " sudo systemctl start $SERVICE_NAME"

View file

@ -1,142 +1,106 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# install_for_python - Provision the Python-based radio automation stack. #
# Must be run as root from within the target directory (e.g. /srv/radio/). # install_for_python.sh - Installs the Python-based radio automation stack.
# Idempotent: safe to re-run. # Derives its service name from the directory it lives in, prompts for a
# storage path (kept out of the boot drive), writes config.json, creates
# the systemd unit, and sets up cron entries.
#
set -euo pipefail set -euo pipefail
INSTALL_DIR="$(pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE_NAME="$(basename "$INSTALL_DIR")" DIR_NAME="$(basename "$SCRIPT_DIR")"
VENV="$INSTALL_DIR/.venv" SERVICE_NAME="${DIR_NAME}"
PYTHON_BIN="$VENV/bin/python" SYSTEMD_UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
CONFIG_FILE="${SCRIPT_DIR}/config.json"
echo "=== Radio Automation Installer (Python) ===" echo "=== ${SERVICE_NAME} installer (Python) ==="
echo "Install dir: $INSTALL_DIR" echo "Install dir: ${SCRIPT_DIR}"
echo "Service name: $SERVICE_NAME" echo ""
echo
# --- Prompt for config values (pre-fill from existing config.json) --- # --- Prompt for storage path ---------------------------------------------
EXISTING_CONFIG="$INSTALL_DIR/config.json" DEFAULT_STORAGE="/srv/radio-storage"
declare -A CFG read -rp "Enter storage path for media/state/logs [${DEFAULT_STORAGE}]: " STORAGE_PATH
CFG[ICE_HOST]="localhost" STORAGE_PATH="${STORAGE_PATH:-$DEFAULT_STORAGE}"
CFG[ICE_PORT]="7777" mkdir -p "${STORAGE_PATH}"/{music,podcasts,jingles,announcements,state,playlists,logs}
CFG[ICE_MOUNT]="/audio.mp3" chown -R liquidsoap:liquidsoap "${STORAGE_PATH}" 2>/dev/null || true
CFG[ICE_USER]="source"
CFG[ICE_PASS]=""
CFG[GPODDER_ENABLE]="false"
CFG[GPODDER_HOST]="https://gpodder.net"
CFG[GPODDER_USER]=""
CFG[GPODDER_PASS]=""
if [[ -f "$EXISTING_CONFIG" ]]; then # --- Collect Icecast credentials ------------------------------------------
echo "Existing config.json found; using as defaults." read -rp "Icecast source port [8000]: " IC_PORT
CFG[ICE_HOST]=$(jq -r '.icecast.host // "localhost"' "$EXISTING_CONFIG") IC_PORT="${IC_PORT:-8000}"
CFG[ICE_PORT]=$(jq -r '.icecast.port // 7777' "$EXISTING_CONFIG") read -rp "Icecast mount [/radio.mp3]: " IC_MOUNT
CFG[ICE_MOUNT]=$(jq -r '.icecast.mount // "/audio.mp3"' "$EXISTING_CONFIG") IC_MOUNT="${IC_MOUNT:-/radio.mp3}"
CFG[ICE_USER]=$(jq -r '.icecast.username // "source"' "$EXISTING_CONFIG") read -rp "Icecast source username [sourceadmin]: " IC_USER
CFG[ICE_PASS]=$(jq -r '.icecast.password // ""' "$EXISTING_CONFIG") IC_USER="${IC_USER:-sourceadmin}"
CFG[GPODDER_ENABLE]=$(jq -r '.gpodder.enable // false' "$EXISTING_CONFIG") read -rsp "Icecast source password: " IC_PASS; echo
CFG[GPODDER_HOST]=$(jq -r '.gpodder.host // "https://gpodder.net"' "$EXISTING_CONFIG") IC_PASS="${IC_PASS:?Password required}"
CFG[GPODDER_USER]=$(jq -r '.gpodder.username // ""' "$EXISTING_CONFIG")
CFG[GPODDER_PASS]=$(jq -r '.gpodder.password // ""' "$EXISTING_CONFIG")
fi
read -rp "Icecast host [$CFG[ICE_HOST]]: " v; CFG[ICE_HOST]=${v:-$CFG[ICE_HOST]} # --- gPodder sync (optional) ----------------------------------------------
read -rp "Icecast port [$CFG[ICE_PORT]]: " v; CFG[ICE_PORT]=${v:-$CFG[ICE_PORT]} read -rp "Enable gPodder.net sync? [y/N]: " GPODDER_ENABLE
read -rp "Icecast mount [$CFG[ICE_MOUNT]]: " v; CFG[ICE_MOUNT]=${v:-$CFG[ICE_MOUNT]} case "${GPODDER_ENABLE,,}" in
read -rp "Icecast source username [$CFG[ICE_USER]]: " v; CFG[ICE_USER]=${v:-$CFG[ICE_USER]} y|yes)
read -rsp "Icecast source password [$CFG[ICE_PASS]]: "; echo; CFG[ICE_PASS]=${v:-$CFG[ICE_PASS]} read -rp "gPodder host [https://gpodder.net]: " GP_HOST
read -rp "Enable gPodder sync? (true/false) [$CFG[GPODDER_ENABLE]]: " v; CFG[GPODDER_ENABLE]=${v:-$CFG[GPODDER_ENABLE]} GP_HOST="${GP_HOST:-https://gpodder.net}"
read -rp "gPodder host [$CFG[GPODDER_HOST]]: " v; CFG[GPODDER_HOST]=${v:-$CFG[GPODDER_HOST]} read -rp "gPodder username: " GP_USER
read -rp "gPodder username [$CFG[GPODDER_USER]]: " v; CFG[GPODDER_USER]=${v:-$CFG[GPODDER_USER]} read -rsp "gPodder password: " GP_PASS; echo
read -rsp "gPodder password [$CFG[GPODDER_PASS]]: "; echo; CFG[GPODDER_PASS]=${v:-$CFG[GPODDER_PASS]} GP_JSON=$(jq -n --arg h "$GP_HOST" --arg u "$GP_USER" --arg p "$GP_PASS" \
'{enable:true, host:$h, username:$u, password:$p}')
;;
*)
GP_JSON='{"enable":false,"host":"","username":"","password":""}'
;;
esac
echo # --- Write config.json -----------------------------------------------------
echo "Summary:" cat > "$CONFIG_FILE" <<EOF
echo " Icecast: ${CFG[ICE_HOST]}:${CFG[ICE_PORT]}${CFG[ICE_MOUNT]}"
echo " gPodder sync: ${CFG[GPODDER_ENABLE]}"
read -rp "Proceed? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
# --- Install system packages ---
echo "Installing system packages..."
apt-get update -qq
apt-get install -y -qq python3 python3-venv python3-pip jq curl >/dev/null
# --- Create venv and install Python deps ---
if [[ ! -d "$VENV" ]]; then
echo "Creating virtualenv at $VENV..."
python3 -m venv "$VENV"
fi
echo "Installing Python dependencies..."
"$VENV/bin/pip" install --quiet --upgrade pip
"$VENV/bin/pip" install --quiet feedparser requests mutagen
# --- Create directory structure ---
for d in music podcasts jingles announcements playlists state logs; do
mkdir -p "$INSTALL_DIR/$d"
done
# --- Generate config.json ---
cat > "$INSTALL_DIR/config.json" <<EOF
{ {
"storage": "${STORAGE_PATH}",
"icecast": { "icecast": {
"host": "${CFG[ICE_HOST]}", "host": "127.0.0.1",
"port": ${CFG[ICE_PORT]}, "port": ${IC_PORT},
"mount": "${CFG[ICE_MOUNT]}", "mount": "${IC_MOUNT}",
"username": "${CFG[ICE_USER]}", "username": "${IC_USER}",
"password": "${CFG[ICE_PASS]}" "password": "${IC_PASS}"
}, },
"gpodder": { "gpodder": ${GP_JSON}
"enable": ${CFG[GPODDER_ENABLE]},
"host": "${CFG[GPODDER_HOST]}",
"username": "${CFG[GPODDER_USER]}",
"password": "${CFG[GPODDER_PASS]}"
}
} }
EOF EOF
chmod 600 "$INSTALL_DIR/config.json" chmod 600 "$CONFIG_FILE"
echo "Wrote ${CONFIG_FILE}"
# --- Ensure liquidsoap user exists --- # --- Dependencies -----------------------------------------------------------
if ! id liquidsoap &>/dev/null; then echo "Installing Python dependencies..."
useradd --system --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin liquidsoap pip3 install feedparser requests 2>/dev/null || pip install feedparser requests
fi
chown -R liquidsoap:liquidsoap "$INSTALL_DIR"
# --- Write systemd service --- # --- Systemd unit ------------------------------------------------------------
cat > /etc/systemd/system/"$SERVICE_NAME".service <<EOF cat > "$SYSTEMD_UNIT" <<EOF
[Unit] [Unit]
Description=Liquidsoap radio station ($SERVICE_NAME) Description=${SERVICE_NAME} radio automation (liquidsoap)
After=network.target icecast2.service After=network.target icecast2.service
[Service] [Service]
User=liquidsoap User=liquidsoap
WorkingDirectory=$INSTALL_DIR WorkingDirectory=${SCRIPT_DIR}
ExecStart=/usr/bin/liquidsoap $INSTALL_DIR/station.liq ExecStart=/usr/bin/liquidsoap ${SCRIPT_DIR}/station.liq
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
# --- Set up cron jobs (deduplicated) ---
CRON_FETCH="0 * * * * sudo -u liquidsoap $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py >> $INSTALL_DIR/logs/cron_fetch.log 2>&1"
CRON_UPDATE="30 * * * * sudo -u liquidsoap $PYTHON_BIN $INSTALL_DIR/update_playlists.py >> $INSTALL_DIR/logs/cron_update.log 2>&1"
(crontab -l 2>/dev/null | grep -v "fetch_podcasts.py"; echo "$CRON_FETCH") | crontab -
(crontab -l 2>/dev/null | grep -v "update_playlists.py"; echo "$CRON_UPDATE") | crontab -
# --- Reload and enable ---
systemctl daemon-reload systemctl daemon-reload
systemctl enable "$SERVICE_NAME" systemctl enable "${SERVICE_NAME}"
echo "Enabled systemd unit: ${SERVICE_NAME}"
echo # --- Cron --------------------------------------------------------------------
CRON_FETCH="0 * * * * cd ${SCRIPT_DIR} && /usr/bin/python3 fetch_podcasts.py >> ${STORAGE_PATH}/logs/cron.log 2>&1"
CRON_PLAYLISTS="30 * * * * cd ${SCRIPT_DIR} && /usr/bin/python3 update_playlists.py >> ${STORAGE_PATH}/logs/cron.log 2>&1"
(crontab -l 2>/dev/null | grep -v "fetch_podcasts.py\|update_playlists.py"; echo "$CRON_FETCH"; echo "$CRON_PLAYLISTS") | crontab -
echo "Installed cron jobs:"
echo " $CRON_FETCH"
echo " $CRON_PLAYLISTS"
echo ""
echo "=== Installation complete ===" echo "=== Installation complete ==="
echo "Next steps:" echo "Storage root: ${STORAGE_PATH}"
echo " 1. Drop MP3/M4A files into $INSTALL_DIR/music/" echo "Start with: systemctl start ${SERVICE_NAME}"
echo " 2. Edit $INSTALL_DIR/schedule.txt with your show schedule"
echo " 3. Review $INSTALL_DIR/station.liq"
echo " 4. Start services:"
echo " sudo systemctl start icecast2"
echo " sudo systemctl start $SERVICE_NAME"

View file

@ -3,16 +3,18 @@
# --------------------------------------------------------------- # ---------------------------------------------------------------
# station.liq - Liquidsoap configuration for radio automation # station.liq - Liquidsoap configuration for radio automation
# Auto-detects its own location so all paths are relative. # Auto-detects its own location so all paths are relative.
# Music/jingles/announcements live under the storage path from
# config.json; code stays in the install dir.
# --------------------------------------------------------------- # ---------------------------------------------------------------
configure.bindir() configure.bindir()
set("log.file.path", "#{bindir()}/logs/liquidsoap.log")
set("log.stdout", true) set("log.stdout", true)
set("log.level", 3) set("log.level", 3)
# --- Load Icecast credentials from config.json --- # --- Load Icecast credentials + storage path from config.json ---
let json.parse (cfg : { let cfg : {
storage: string,
icecast: { icecast: {
host: string, host: string,
port: int, port: int,
@ -26,22 +28,24 @@ let json.parse (cfg : {
username: string, username: string,
password: string password: string
} }
}) = file.contents("#{bindir()}/config.json") } = json.parse(file.contents("#{bindir()}/config.json"))
let ic_host = cfg.icecast.host let ic_host = cfg.icecast.host
let ic_port = cfg.icecast.port let ic_port = cfg.icecast.port
let ic_mount = cfg.icecast.mount let ic_mount = cfg.icecast.mount
let ic_username = cfg.icecast.username let ic_username = cfg.icecast.username
let ic_password = cfg.icecast.password let ic_password = cfg.icecast.password
let storage = cfg.storage
set("log.file.path", "#{storage}/logs/liquidsoap.log")
# --- Background music library (recursive scan for mp3/m4a) --- # --- Background music library (recursive scan for mp3/m4a) ---
music_dir = "#{bindir()}/music"
music_playlist = music_playlist =
request.cue( request.cue(
playlist( playlist(
recurse=true, recurse=true,
pattern="\\.(mp3|m4a)$", pattern="\\.(mp3|m4a)$",
"#{music_dir}" "#{storage}/music"
) )
) )
@ -49,8 +53,8 @@ music_playlist =
sched_queue = request.queue(id="scheduler") sched_queue = request.queue(id="scheduler")
# --- Jingles / announcements (optional) --- # --- Jingles / announcements (optional) ---
jingle_dir = "#{bindir()}/jingles" jingle_dir = "#{storage}/jingles"
announce_dir = "#{bindir()}/announcements" announce_dir = "#{storage}/announcements"
def has_audio(dir) = def has_audio(dir) =
try try

View file

@ -1,37 +1,57 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
update_playlists.py - Regenerate per-show .pls playlists based on playback history. update_playlists.py - Regenerate per-show .pls playlists based on playback history.
Runs via cron hourly at :30. Runs via cron hourly at :30. All data under the storage path from config.json.
""" """
import argparse import argparse
import json import json
import logging import logging
import re
import sqlite3 import sqlite3
import sys import sys
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
STATE_DIR = ROOT / "state" CONFIG_PATH = ROOT / "config.json"
SUBS_DB = STATE_DIR / "subscriptions.db"
PLAYED_DB = STATE_DIR / "played.db"
PODCASTS_DIR = ROOT / "podcasts"
PLAYLISTS_DIR = ROOT / "playlists"
LOGS_DIR = ROOT / "logs"
AUDIO_EXTS = {".mp3", ".m4a"} AUDIO_EXTS = {".mp3", ".m4a"}
STATE_DIR = None
SUBS_DB = None
PLAYED_DB = None
PODCASTS_DIR = None
PLAYLISTS_DIR = None
LOGS_DIR = None
def load_config():
with open(CONFIG_PATH) as f:
return json.load(f)
def init_paths():
global STATE_DIR, SUBS_DB, PLAYED_DB, PODCASTS_DIR, PLAYLISTS_DIR, LOGS_DIR
cfg = load_config()
storage = Path(cfg["storage"]).expanduser().resolve()
STATE_DIR = storage / "state"
SUBS_DB = STATE_DIR / "subscriptions.db"
PLAYED_DB = STATE_DIR / "played.db"
PODCASTS_DIR = storage / "podcasts"
PLAYLISTS_DIR = storage / "playlists"
LOGS_DIR = storage / "logs"
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s", format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[ handlers=[
logging.FileHandler(LOGS_DIR / "update.log"),
logging.StreamHandler(sys.stdout), logging.StreamHandler(sys.stdout),
], ],
) )
log = logging.getLogger("update_playlists") log = logging.getLogger("update_playlists")
def _setup_logging():
fh = logging.FileHandler(LOGS_DIR / "update.log")
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
log.addHandler(fh)
def open_subs_db(): def open_subs_db():
conn = sqlite3.connect(SUBS_DB) conn = sqlite3.connect(SUBS_DB)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@ -56,7 +76,6 @@ def open_played_db():
return conn return conn
def find_audio_files(directory): def find_audio_files(directory):
"""Recursively scan for .mp3/.m4a files."""
results = [] results = []
if not directory.exists(): if not directory.exists():
return results return results
@ -66,34 +85,28 @@ def find_audio_files(directory):
return results return results
def select_unplayed_episode(slug, played_db): def select_unplayed_episode(slug, played_db):
"""Pick one unplayed episode for the show, falling back to least-recently-played."""
files = find_audio_files(PODCASTS_DIR / slug) files = find_audio_files(PODCASTS_DIR / slug)
if not files: if not files:
return None return None
played_rows = played_db.execute( played_rows = played_db.execute(
"SELECT file_path, played_at FROM episodes WHERE show_slug = ? AND played_at IS NOT NULL", "SELECT file_path, played_at FROM episodes WHERE show_slug = ? AND played_at IS NOT NULL",
(slug,), (slug,),
).fetchall() ).fetchall()
played_paths = {row["file_path"]: row["played_at"] for row in played_rows} played_paths = {row["file_path"]: row["played_at"] for row in played_rows}
unplayed = [f for f in files if f not in played_paths] unplayed = [f for f in files if f not in played_paths]
if unplayed: if unplayed:
return unplayed[0] return unplayed[0]
# All played: pick least recently played
if played_paths: if played_paths:
return min(played_paths.items(), key=lambda kv: (kv[1] or ""))[0] return min(played_paths.items(), key=lambda kv: (kv[1] or ""))[0]
return files[0] return files[0]
def write_pls(filepath, out_path): def write_pls(filepath, out_path):
"""Write a .pls playlist pointing at a single file."""
abs_path = str(Path(filepath).resolve()) abs_path = str(Path(filepath).resolve())
content = f"[playlist]\nFile1={abs_path}\nTitle1=Radio Episode\nLength1=-1\nNumberOfEntries=1\nVersion=2\n" content = f"[playlist]\nFile1={abs_path}\nTitle1=Radio Episode\nLength1=-1\nNumberOfEntries=1\nVersion=2\n"
out_path.parent.mkdir(parents=True, exist_ok=True) out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(content) out_path.write_text(content)
def mark_as_played(slug, filepath, duration, played_db): def mark_as_played(slug, filepath, played_db):
played_db.execute( played_db.execute(
"UPDATE episodes SET played_at = datetime('now') WHERE show_slug = ? AND file_path = ?", "UPDATE episodes SET played_at = datetime('now') WHERE show_slug = ? AND file_path = ?",
(slug, filepath), (slug, filepath),
@ -104,25 +117,16 @@ def update_all():
subs_db = open_subs_db() subs_db = open_subs_db()
played_db = open_played_db() played_db = open_played_db()
shows = subs_db.execute("SELECT slug, name FROM shows ORDER BY name").fetchall() shows = subs_db.execute("SELECT slug, name FROM shows ORDER BY name").fetchall()
for show in shows: for show in shows:
slug = show["slug"] slug = show["slug"]
selected = select_unplayed_episode(slug, played_db) selected = select_unplayed_episode(slug, played_db)
if selected is None: if selected is None:
log.info("%s: no audio files found, skipping.", slug) log.info("%s: no audio files found, skipping.", slug)
continue continue
duration_row = played_db.execute(
"SELECT duration_seconds FROM episodes WHERE show_slug = ? AND file_path = ?",
(slug, selected),
).fetchone()
duration = duration_row["duration_seconds"] if duration_row else None
out_pls = PLAYLISTS_DIR / f"{slug}.pls" out_pls = PLAYLISTS_DIR / f"{slug}.pls"
write_pls(selected, out_pls) write_pls(selected, out_pls)
mark_as_played(slug, selected, duration, played_db) mark_as_played(slug, selected, played_db)
log.info("%s: queued %s", slug, Path(selected).name) log.info("%s: queued %s", slug, Path(selected).name)
subs_db.close() subs_db.close()
played_db.close() played_db.close()
@ -148,9 +152,11 @@ def main():
parser.add_argument("--json", action="store_true", help="Emit JSON summary and exit") parser.add_argument("--json", action="store_true", help="Emit JSON summary and exit")
args = parser.parse_args() args = parser.parse_args()
STATE_DIR.mkdir(exist_ok=True) init_paths()
LOGS_DIR.mkdir(exist_ok=True) STATE_DIR.mkdir(parents=True, exist_ok=True)
PLAYLISTS_DIR.mkdir(exist_ok=True) LOGS_DIR.mkdir(parents=True, exist_ok=True)
PLAYLISTS_DIR.mkdir(parents=True, exist_ok=True)
_setup_logging()
if args.json: if args.json:
json_summary() json_summary()
@ -159,4 +165,3 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View file

@ -8,14 +8,29 @@ require "optparse"
module RadioAutomation module RadioAutomation
ROOT = File.expand_path("..", __dir__) ROOT = File.expand_path("..", __dir__)
STATE_DIR = File.join(ROOT, "state") CONFIG_PATH = File.join(ROOT, "config.json")
SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
PLAYED_DB = File.join(STATE_DIR, "played.db")
PODCASTS_DIR = File.join(ROOT, "podcasts")
PLAYLISTS_DIR = File.join(ROOT, "playlists")
LOGS_DIR = File.join(ROOT, "logs")
AUDIO_EXTS = [".mp3", ".m4a"] AUDIO_EXTS = [".mp3", ".m4a"]
STORAGE_DIR = nil
STATE_DIR = nil
SUBS_DB = nil
PLAYED_DB = nil
PODCASTS_DIR = nil
PLAYLISTS_DIR = nil
LOGS_DIR = nil
def self.init_paths
cfg = JSON.parse(File.read(CONFIG_PATH))
@storage = File.expand_path(cfg["storage"])
self.STORAGE_DIR = @storage
self.STATE_DIR = File.join(@storage, "state")
self.SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
self.PLAYED_DB = File.join(STATE_DIR, "played.db")
self.PODCASTS_DIR = File.join(@storage, "podcasts")
self.PLAYLISTS_DIR = File.join(@storage, "playlists")
self.LOGS_DIR = File.join(@storage, "logs")
end
def self.log_info(msg) def self.log_info(msg)
puts "#{Time.now.iso8601} [INFO] #{msg}" puts "#{Time.now.iso8601} [INFO] #{msg}"
append_log("update.log", msg) append_log("update.log", msg)
@ -139,6 +154,7 @@ module RadioAutomation
opts.on("--json", "Emit JSON summary and exit") { options[:json] = true } opts.on("--json", "Emit JSON summary and exit") { options[:json] = true }
end.parse! end.parse!
init_paths
FileUtils.mkdir_p(STATE_DIR) FileUtils.mkdir_p(STATE_DIR)
FileUtils.mkdir_p(LOGS_DIR) FileUtils.mkdir_p(LOGS_DIR)
FileUtils.mkdir_p(PLAYLISTS_DIR) FileUtils.mkdir_p(PLAYLISTS_DIR)