Minor tweaks

This commit is contained in:
G. Gibson 2026-08-26 15:23:22 -07:00
commit 952c9ae5a9
9 changed files with 1408 additions and 2024 deletions

37
.gitignore vendored Normal file
View file

@ -0,0 +1,37 @@
# Local Ruby gems (installed by install_for_jruby)
.gems/
# Python virtualenv
.venv/
# Runtime state: SQLite DBs and journals
state/
*.db
*.db-journal
*.sqlite3
*.sqlite3-journal
# Logs
logs/
*.log
# Large / regenerable media directories
music/
podcasts/
jingles/
announcements/
# Generated playlist files
playlists/
*.pls
# Secrets
config.json
# OS / editor noise
.DS_Store
Thumbs.db
*.swp
*~
.idea/
.vscode/

View file

@ -1,3 +1,5 @@
[![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 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.
@ -48,7 +50,6 @@ The moving parts:
| `config.json` | Credentials and connection settings | No (secret) |
| `schedule.txt` | Human-edited cron schedule of shows/streams | Yes |
| `station.liq` | liquidsoap configuration | Yes |
| `Gemfile` | Ruby gem dependencies (Ruby stack) | Yes |
## Configuration: `config.json`
@ -214,7 +215,7 @@ For each show, the updater scans `podcasts/<slug>/` recursively for audio files,
The liquidsoap program itself. Not invoked directly — it runs under the systemd service. Key behaviors:
- Resolves all paths relative to its own location via `configure.bindir()`, so the whole tree can be relocated without editing the file.
- Loads Icecast credentials from `config.json`.
- Loads Icecast credentials from `config.json` using an annotated `json.parse` binding.
- Maintains a continuous random background-music playlist drawn from `music/`.
- Uses a request queue as the primary source: when a scheduled show or stream is triggered, it plays ahead of the music fallback; when the queue drains, background music resumes.

View file

@ -1,459 +1,394 @@
#!/usr/bin/env python3
"""Fetch podcast episodes from RSS feeds, manage subscriptions, and download audio."""
"""
fetch_podcasts.py - Podcast subscription management and episode fetching
for the liquidsoap radio automation stack.
"""
import os
import sys
import argparse
import json
import logging
import re
import shutil
import sqlite3
import hashlib
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from datetime import datetime
from urllib.parse import urlparse
from urllib.parse import quote
import feedparser
import requests
AUDIO_ROOT = Path(__file__).resolve().parent
DB_PATH = AUDIO_ROOT / "state" / "subscriptions.db"
DOWNLOAD_DIR = AUDIO_ROOT / "podcasts"
CONFIG_PATH = AUDIO_ROOT / "config.json"
ROOT = Path(__file__).resolve().parent
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"}
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOGS_DIR / "fetch.log"),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger("fetch_podcasts")
def log_error(msg):
log.error(msg)
def load_config():
with open(CONFIG_PATH, "r") as f:
with open(CONFIG_PATH) as f:
return json.load(f)
def get_db():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.execute("""CREATE TABLE IF NOT EXISTS shows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
feed_url TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
opml_import INTEGER DEFAULT 0
)""")
conn.execute("""CREATE TABLE IF NOT EXISTS seen (
url TEXT PRIMARY KEY,
title TEXT,
show_slug TEXT,
duration_sec INTEGER DEFAULT 0,
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""")
# Migrate existing databases that lack the new column
cols = [row[1] for row in conn.execute("PRAGMA table_info(shows)").fetchall()]
if "opml_import" not in cols:
conn.execute("ALTER TABLE shows ADD COLUMN opml_import INTEGER DEFAULT 0")
def open_subs_db():
conn = sqlite3.connect(SUBS_DB)
conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE IF NOT EXISTS shows (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
feed_url TEXT NOT NULL UNIQUE,
source TEXT DEFAULT 'manual',
opml_import INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)
""")
conn.commit()
return conn
def open_played_db():
conn = sqlite3.connect(PLAYED_DB)
conn.row_factory = sqlite3.Row
conn.execute("""
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,
duration_seconds INTEGER,
played_at TEXT DEFAULT (datetime('now')),
UNIQUE(show_slug, guid)
)
""")
conn.commit()
return conn
def sanitize_slug(name):
"""Convert a show name to a filesystem-safe slug."""
slug = name.lower().strip()
slug = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in slug)
slug = "_".join(slug.split("_"))
return slug[:80]
def slugify(name):
s = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
return s[:60] or "show"
def gpodder_sync(cfg):
g = cfg["gpodder"]
base = g["host"].rstrip("/")
username = g["username"]
password = g["password"]
url = f"{base}/subscriptions/{quote(username, safe='')}.opml"
def register_shows_from_opml_xml(xml_bytes, db, opml_import=False):
"""Register shows from OPML XML data.
print(f"--- Syncing subscriptions from {base} ---")
print(f"Fetching subscriptions for '{username}'...")
Uses upsert semantics: if a show with the same feed_url already exists,
no duplicate row is created. Its opml_import flag is upgraded to 1 if
this import marks it as such (protecting it from gpodder pruning).
"""
root = ET.fromstring(xml_bytes)
added = 0
updated_flag = 0
skipped = 0
resp = requests.get(
url,
auth=(username, password),
headers={"User-Agent": "radio-automation/1.0"},
timeout=60,
)
if resp.status_code == 200:
body = resp.text
if not body.strip():
log_error("gPodder sync returned an empty body.")
return []
return parse_opml(body)
elif resp.status_code == 401:
log_error("gPodder sync failed: 401 Unauthorized. Check username/password in config.json.")
elif resp.status_code == 404:
log_error("gPodder sync failed: 404 Not Found. User may not exist or has no subscriptions.")
elif resp.status_code == 400:
log_error("gPodder sync failed: 400 Bad Request.")
else:
log_error(f"gPodder sync failed: unexpected response {resp.status_code}: {resp.text[:200]}")
return []
def parse_opml(xml_string):
shows = []
try:
root = ET.fromstring(xml_string)
except ET.ParseError as e:
log_error(f"Failed to parse OPML XML: {e}")
return []
for outline in root.iter("outline"):
xml_url = outline.get("xmlUrl", "")
if not xml_url:
continue
otype = outline.get("type", "")
if otype and otype != "rss":
feed_url = (outline.attrib.get("xmlUrl") or "").strip()
name = (outline.attrib.get("text") or "").strip()
if not feed_url or not re.match(r"^https?://", feed_url):
continue
shows.append({"name": name, "feed_url": feed_url})
return shows
show_name = outline.get("text") or outline.get("title") or "Unknown Show"
slug = sanitize_slug(show_name)
flag = 1 if opml_import else 0
existing = db.execute(
"SELECT id, opml_import FROM shows WHERE feed_url=?", (xml_url,)
).fetchone()
if existing:
existing_id, existing_flag = existing
if flag == 1 and existing_flag == 0:
def register_remote_shows(remote_shows):
db = open_subs_db()
added = 0
for show in remote_shows:
slug = slugify(show["name"])
existing = db.execute("SELECT slug FROM shows WHERE slug = ?", (slug,)).fetchone()
if existing is None:
db.execute(
"UPDATE shows SET opml_import=1 WHERE id=?", (existing_id,)
)
updated_flag += 1
print(f" Protected existing: {show_name} ({slug})")
else:
skipped += 1
else:
db.execute(
"INSERT INTO shows (name, feed_url, slug, opml_import) VALUES (?, ?, ?, ?)",
(show_name, xml_url, slug, flag)
"INSERT INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'gpodder', 0)",
(slug, show["name"], show["feed_url"]),
)
log.info("Registered new show: %s (%s)", show["name"], slug)
added += 1
print(f" Registered: {show_name} -> {slug}")
db.commit()
if added:
print(f" Added {added} new show(s).")
if updated_flag:
print(f" Upgraded {updated_flag} show(s) to protected.")
if skipped:
print(f" Skipped {skipped} duplicate(s).")
db.close()
return added
def import_opml_file(filepath):
"""Import shows from a local OPML file."""
try:
with open(filepath, "rb") as f:
xml_data = f.read()
except FileNotFoundError:
print(f"ERROR: File not found: {filepath}")
sys.exit(1)
db = get_db()
count = register_shows_from_opml_xml(xml_data, db, opml_import=True)
total = db.execute("SELECT COUNT(*) FROM shows").fetchone()[0]
db.close()
print(f"\nImport complete. {count} new show(s) added. Total registered: {total}")
def sync_gpoddernet():
"""Sync subscriptions from gpodder.net, register new shows, prune removed ones."""
cfg = load_config()
gp = cfg["gpodder"]
if not gp.get("enable", False):
print("gpodder.net sync is disabled in config.json.")
return
if not gp.get("username") or not gp.get("password"):
print("ERROR: gpodder.username/gpodder.password not set in config.json")
sys.exit(1)
url = f"{gp['host']}/subscriptions/{gp['username']}.opml"
print(f"Fetching subscriptions from {gp['host']} for '{gp['username']}'...")
resp = requests.get(url, auth=(gp["username"], gp["password"]), timeout=30)
if resp.status_code == 401:
print("ERROR: Authentication failed. Check username/password in config.json.")
sys.exit(1)
elif resp.status_code != 200:
print(f"ERROR: Unexpected response {resp.status_code}: {resp.text[:200]}")
sys.exit(1)
db = get_db()
# Collect all feed URLs from the current subscription list
root = ET.fromstring(resp.content)
gp_feed_urls = set()
for outline in root.iter("outline"):
fu = outline.get("xmlUrl", "")
if fu:
gp_feed_urls.add(fu)
# Register any new shows
count = register_shows_from_opml_xml(resp.content, db, opml_import=False)
total = db.execute("SELECT COUNT(*) FROM shows").fetchone()[0]
# Prune shows that were unsubscribed
prune_removed_shows(gp_feed_urls, db)
db.close()
print(f"\nSync complete. {count} new, total registered: {total}")
def prune_removed_shows(gp_feed_urls, db):
"""Remove shows not in current gpodder list (unless opml_import=1)."""
rows = db.execute(
"SELECT slug, feed_url, name FROM shows WHERE opml_import = 0"
def prune_stale_shows(remote_shows):
db = open_subs_db()
remote_slugs = {slugify(s["name"]) for s in remote_shows}
stale = db.execute(
"SELECT slug, name FROM shows WHERE source = 'gpodder' AND opml_import = 0"
).fetchall()
to_remove = []
for slug, feed_url, name in rows:
if feed_url not in gp_feed_urls:
to_remove.append((slug, name))
if not to_remove:
return
for slug, name in to_remove:
print(f" Removing unsubscribed show: {name} ({slug})")
db.execute("DELETE FROM seen WHERE show_slug=?", (slug,))
db.execute("DELETE FROM shows WHERE slug=?", (slug))
show_dir = DOWNLOAD_DIR / slug
if show_dir.exists():
shutil.rmtree(show_dir)
print(f" Deleted directory: {show_dir}")
db.commit()
print(f" Pruned {len(to_remove)} removed show(s).")
def delete_show(slug):
"""Manually delete a show and all associated data."""
db = get_db()
row = db.execute("SELECT name FROM shows WHERE slug=?", (slug,)).fetchone()
if not row:
print(f"No show found with slug '{slug}'.")
db.close()
return
name = row[0]
print(f"Deleting show: {name} ({slug})")
db.execute("DELETE FROM seen WHERE show_slug=?", (slug,))
db.execute("DELETE FROM shows WHERE slug=?", (slug))
removed = 0
for row in stale:
if row["slug"] not in remote_slugs:
remove_show_data(row["slug"])
db.execute("DELETE FROM shows WHERE slug = ?", (row["slug"],))
log.info("Pruned stale show: %s (%s)", row["name"], row["slug"])
removed += 1
db.commit()
db.close()
return removed
show_dir = DOWNLOAD_DIR / slug
if show_dir.exists():
shutil.rmtree(show_dir)
print(f"Deleted directory: {show_dir}")
pls_file = AUDIO_ROOT / "playlists" / f"{slug}.pls"
if pls_file.exists():
pls_file.unlink()
print(f"Deleted playlist: {pls_file}")
print("Done.")
def add_show(feed_url):
"""Register a new show from a feed URL and fetch its initial episodes."""
print(f"Fetching feed: {feed_url}")
def extract_duration(entry):
dur = entry.get("media_duration") or entry.get("duration")
if dur:
try:
d = feedparser.parse(feed_url)
except Exception as e:
print(f"ERROR: Failed to parse feed: {e}")
sys.exit(1)
if d.bozo and not d.entries:
print(f"ERROR: Invalid or unreachable feed. Bozo exception: {d.get('bozo_exception', 'unknown')}")
sys.exit(1)
show_name = d.feed.get("title", "Unknown Show")
slug = sanitize_slug(show_name)
entry_count = len(d.entries)
print(f" Title: {show_name}")
print(f" Slug: {slug}")
print(f" Entries found: {entry_count}")
db = get_db()
existing = db.execute("SELECT name FROM shows WHERE feed_url=?", (feed_url,)).fetchone()
if existing:
print(f"NOTE: Feed already registered as '{existing[0]}'. Nothing to do.")
db.close()
return
db.execute(
"INSERT INTO shows (name, feed_url, slug, opml_import) VALUES (?, ?, ?, 1)",
(show_name, feed_url, slug)
)
db.commit()
print(f" Registered: {show_name} -> {slug}")
print(f"\n--- Fetching episodes ---")
fetch_feed(show_name, feed_url, slug, db)
db.close()
print(f"\nDone. Episodes saved to: {DOWNLOAD_DIR / slug}/")
def fetch_feed(show_name, feed_url, slug, db):
"""Parse a feed and download any new episodes."""
d = feedparser.parse(feed_url)
if d.bozo and not d.entries:
print(f"[{show_name}] ERROR: Could not parse feed.")
return
show_dir = DOWNLOAD_DIR / slug
show_dir.mkdir(parents=True, exist_ok=True)
new_count = 0
for entry in d.entries:
link = entry.get("link", "")
title = entry.get("title", "untitled")
# Find the enclosure (audio file)
enclosure = None
if hasattr(entry, "enclosures") and entry.enclosures:
enc = entry.enclosures[0]
enclosure = {"url": enc.href, "type": enc.type, "length": getattr(enc, "length", "0")}
elif "media_content" in entry:
mc = entry.media_content[0]
enclosure = {"url": mc.url, "type": mc.type, "length": getattr(mc, "duration", "0")}
if not enclosure:
continue
ep_url = enclosure["url"]
# Check if already downloaded
row = db.execute("SELECT 1 FROM seen WHERE url=?", (ep_url,)).fetchone()
if row:
continue
# Extract duration from enclosure length (seconds) or media:duration
duration_sec = 0
try:
raw_len = str(enclosure.get("length", "0"))
if ":" in raw_len:
parts = raw_len.split(":")
duration_sec = int(parts[-1])
else:
duration_sec = int(raw_len)
except (ValueError, IndexError):
return int(float(dur))
except (ValueError, TypeError):
pass
# Sanitize filename
safe_title = "".join(c if c.isalnum() or c in ("-", "_", " ") else "_" for c in title)
safe_title = safe_title.strip()[:120]
ext = ".mp3"
if "ogg" in enclosure.get("type", "").lower():
ext = ".ogg"
elif "m4a" in enclosure.get("type", "").lower() or "aac" in enclosure.get("type", "").lower():
ext = ".m4a"
filepath = show_dir / f"{safe_title}{ext}"
if filepath.exists():
db.execute(
"INSERT OR IGNORE INTO seen (url, title, show_slug, duration_sec) VALUES (?, ?, ?, ?)",
(ep_url, title, slug, duration_sec)
)
db.commit()
continue
# Download
iso = entry.get("iso_8601_duration")
if iso:
m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", iso)
if m:
h, mn, s = (int(g) if g else 0 for g in m.groups())
return h * 3600 + mn * 60 + s
enclosures = entry.get("enclosures") or []
if enclosures:
length = enclosures[0].get("length")
if length:
try:
print(f"[{show_name}] Downloading: {title}")
resp = requests.get(ep_url, stream=True, timeout=120)
resp.raise_for_status()
with open(filepath, "wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
f.write(chunk)
return int(int(length) * 8 / 128000)
except ValueError:
pass
return None
db.execute(
"INSERT OR IGNORE INTO seen (url, title, show_slug, duration_sec) VALUES (?, ?, ?, ?)",
(ep_url, title, slug, duration_sec)
)
db.commit()
new_count += 1
def fetch_feed(feed_url):
try:
parsed = feedparser.parse(feed_url)
except Exception as e:
print(f"[{show_name}] FAILED to download '{title}': {e}")
log_error(f"Feed parse error for {feed_url}: {e}")
return None
if parsed.bozo and not parsed.entries:
log_error(f"Bozo feed (no entries) for {feed_url}: {parsed.bozo_exception}")
return None
return parsed
if new_count:
print(f"[{show_name}] Downloaded {new_count} new episode(s).")
else:
print(f"[{show_name}] No new episodes.")
def download_episode(url, dest_dir, filename):
dest = dest_dir / filename
if dest.exists():
return str(dest)
try:
with requests.get(url, stream=True, timeout=120) as r:
r.raise_for_status()
tmp = dest.with_suffix(dest.suffix + ".part")
with open(tmp, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
tmp.rename(dest)
return str(dest)
except requests.RequestException as e:
log_error(f"Download failed for {url}: {e}")
return None
def safe_filename(title, fallback):
name = re.sub(r"[^\w\s.-]", "", title or "").strip().replace(" ", "_")
return (name[:120] or fallback) + ".mp3"
def fetch_show_episodes(slug, name, feed_url):
dest_dir = PODCASTS_DIR / slug
dest_dir.mkdir(parents=True, exist_ok=True)
parsed = fetch_feed(feed_url)
if parsed is None:
return 0
played_db = open_played_db()
seen = {
row["guid"]
for row in played_db.execute("SELECT guid FROM episodes WHERE show_slug = ?", (slug,))
}
subs_db = open_subs_db()
new_count = 0
for entry in parsed.entries:
guid = entry.get("id") or entry.get("link") or entry.get("title", "")
if guid in seen:
continue
enclosures = entry.get("enclosures") or []
if not enclosures:
continue
audio_url = enclosures[0].get("href")
if not audio_url:
continue
title = entry.get("title", "untitled")
filename = safe_filename(title, guid[-20:])
file_path = download_episode(audio_url, dest_dir, filename)
if file_path is None:
continue
duration = extract_duration(entry)
played_db.execute(
"INSERT OR IGNORE INTO episodes (show_slug, guid, title, file_path, duration_seconds, played_at) "
"VALUES (?, ?, ?, ?, ?, NULL)",
(slug, guid, title, file_path, duration),
)
new_count += 1
log.info(" New episode: %s [%s]", title, filename)
played_db.commit()
played_db.close()
subs_db.close()
return new_count
def fetch_all_episodes():
db = open_subs_db()
shows = db.execute("SELECT slug, name, feed_url FROM shows ORDER BY name").fetchall()
db.close()
total_new = 0
for show in shows:
log.info("--- Fetching: %s (%s) ---", show["name"], show["slug"])
try:
n = fetch_show_episodes(show["slug"], show["name"], show["feed_url"])
total_new += n
except Exception as e:
log_error(f"Unexpected error fetching {show['slug']}: {e}")
log.info("=== Fetch complete: %d new episode(s) ===", total_new)
def list_shows(detail=False):
"""List all registered shows."""
db = get_db()
rows = db.execute("SELECT name, slug, feed_url, opml_import FROM shows ORDER BY name").fetchall()
db = open_subs_db()
rows = db.execute(
"SELECT slug, name, feed_url, source, opml_import FROM shows ORDER BY name"
).fetchall()
db.close()
if not rows:
print("No shows registered.")
db.close()
return
print(f"{'Show Name':<40} {'Slug':<30} {'Protected':<10}")
print("-" * 80)
for name, slug, feed_url, prot in rows:
prot_str = "yes" if prot else "no"
print(f"{name:<40} {slug:<30} {prot_str:<10}")
print(f"{'SLUG':<30} {'PROTECTED':<10} NAME")
for r in rows:
prot = "yes" if r["opml_import"] else "no"
line = f"{r['slug']:<30} {prot:<10} {r['name']}"
if detail:
print("\nFeed URLs:")
for name, slug, feed_url, prot in rows:
print(f" {slug}: {feed_url}")
line += f"\n{'':<50} {r['feed_url']}"
print(line)
def add_show(feed_url):
parsed = fetch_feed(feed_url)
if parsed is None or not parsed.feed.get("title"):
log_error(f"Could not determine show title from {feed_url}")
return
name = parsed.feed["title"]
slug = slugify(name)
db = open_subs_db()
db.execute(
"INSERT OR IGNORE INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'manual', 0)",
(slug, name, feed_url),
)
db.commit()
db.close()
log.info("Added show: %s (%s)", name, slug)
fetch_show_episodes(slug, name, feed_url)
def remove_show_data(slug):
pod_dir = PODCASTS_DIR / slug
if pod_dir.exists():
shutil.rmtree(pod_dir)
pls = ROOT / "playlists" / f"{slug}.pls"
if pls.exists():
pls.unlink()
def delete_show(slug):
db = open_subs_db()
row = db.execute("SELECT name FROM shows WHERE slug = ?", (slug,)).fetchone()
if row is None:
log_error(f"No show found with slug '{slug}'.")
return
remove_show_data(slug)
db.execute("DELETE FROM shows WHERE slug = ?", (slug,))
db.commit()
db.close()
played_db = open_played_db()
played_db.execute("DELETE FROM episodes WHERE show_slug = ?", (slug,))
played_db.commit()
played_db.close()
log.info("Deleted show: %s (%s)", row["name"], slug)
def import_opml(path):
try:
with open(path) as f:
content = f.read()
except OSError as e:
log_error(f"Cannot read OPML file: {e}")
return
shows = parse_opml(content)
db = open_subs_db()
for show in shows:
slug = slugify(show["name"])
db.execute(
"INSERT OR IGNORE INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'opml', 1)",
(slug, show["name"], show["feed_url"]),
)
db.commit()
db.close()
log.info("OPML import: %d show(s) processed.", len(shows))
def run_fetch(config):
g = config["gpodder"]
if g.get("enable") is True:
remote = gpodder_sync(config)
if not remote:
log.warning("No subscriptions retrieved from gPodder; using local registry only.")
else:
added = register_remote_shows(remote)
pruned = prune_stale_shows(remote)
log.info("Sync: %d added, %d pruned.", added, pruned)
fetch_all_episodes()
def main():
if "--delete-show" in sys.argv:
idx = sys.argv.index("--delete-show")
if idx + 1 < len(sys.argv):
delete_show(sys.argv[idx + 1])
parser = argparse.ArgumentParser(description="Podcast fetcher for radio automation")
parser.add_argument("--list-shows", action="store_true", help="List registered shows")
parser.add_argument("--detail", action="store_true", help="With --list-shows, show feed URLs")
parser.add_argument("--add-show", metavar="FEED_URL", help="Add a show from a feed URL")
parser.add_argument("--delete-show", metavar="SLUG", help="Delete a show and its data")
parser.add_argument("--import-opml", metavar="FILE", help="Import shows from an OPML file")
args = parser.parse_args()
STATE_DIR.mkdir(exist_ok=True)
LOGS_DIR.mkdir(exist_ok=True)
PODCASTS_DIR.mkdir(exist_ok=True)
config = load_config()
if args.list_shows:
list_shows(detail=args.detail)
elif args.add_show:
add_show(args.add_show)
elif args.delete_show:
delete_show(args.delete_show)
elif args.import_opml:
import_opml(args.import_opml)
else:
print("Usage: fetch_podcasts.py --delete-show <slug>")
sys.exit(1)
return
if "--add-show" in sys.argv:
idx = sys.argv.index("--add-show")
if idx + 1 < len(sys.argv):
add_show(sys.argv[idx + 1])
else:
print("Usage: fetch_podcasts.py --add-show <feed-url>")
sys.exit(1)
return
if "--import-opml" in sys.argv:
idx = sys.argv.index("--import-opml")
if idx + 1 < len(sys.argv):
import_opml_file(sys.argv[idx + 1])
else:
print("Usage: fetch_podcasts.py --import-opml <path-to-file.opml>")
sys.exit(1)
return
if "--list-shows" in sys.argv:
list_shows(detail="--detail" in sys.argv)
return
# Normal run: optionally sync gpodder, then fetch all registered shows
cfg = load_config()
gp = cfg.get("gpodder", {})
if gp.get("enable", False):
print("--- Syncing subscriptions from gpodder.net ---")
try:
sync_gpoddernet()
except SystemExit:
raise
except Exception as e:
print(f"! gpodder sync failed (continuing with existing shows): {e}")
db = get_db()
shows = db.execute("SELECT name, feed_url, slug FROM shows").fetchall()
if not shows:
print("No shows registered. Import an OPML file, add a show, or enable gpodder sync.")
db.close()
return
print(f"--- Fetching episodes for {len(shows)} show(s) ---")
for show_name, feed_url, slug in shows:
try:
fetch_feed(show_name, feed_url, slug, db)
except Exception as e:
print(f"[{show_name}] FAILED: {e}")
db.close()
run_fetch(config)
if __name__ == "__main__":
main()

View file

@ -1,482 +1,446 @@
#!/usr/bin/env jruby
# frozen_string_literal: true
#
# fetch_podcasts.rb - Fetch podcast episodes from RSS feeds, manage subscriptions,
# and download audio. JRuby-compatible (Ruby 3.1+ baseline).
require "nokogiri"
require "net/http"
require "uri"
require "cgi"
require "json"
require "sqlite3"
require "rexml/document"
require "digest/md5"
require "fileutils"
require "time"
require "optparse"
AUDIO_ROOT = Pathname.new(File.expand_path(__dir__))
DB_PATH = AUDIO_ROOT.join("state/subscriptions.db")
DOWNLOAD_DIR = AUDIO_ROOT.join("podcasts")
CONFIG_PATH = AUDIO_ROOT.join("config.json")
module RadioAutomation
ROOT = File.expand_path("..", __dir__)
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"]
module Radio
class Fetcher
def initialize
@db = get_db
def self.log_info(msg)
puts "#{Time.now.iso8601} [INFO] #{msg}"
append_log("fetch.log", msg)
end
attr_reader :db
def load_config
JSON.parse(File.read(CONFIG_PATH))
def self.log_error(msg)
puts "#{Time.now.iso8601} [ERROR] #{msg}"
append_log("fetch.log", msg)
end
def get_db
FileUtils.mkdir_p(DB_PATH.dirname)
conn = SQLite3::Database.new(DB_PATH.to_s)
conn.execute(<<~SQL)
CREATE TABLE IF NOT EXISTS shows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
feed_url TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
opml_import INTEGER DEFAULT 0
);
SQL
conn.execute(<<~SQL)
CREATE TABLE IF NOT EXISTS seen (
url TEXT PRIMARY KEY,
title TEXT,
show_slug TEXT,
duration_sec INTEGER DEFAULT 0,
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
SQL
# Migrate older databases lacking the new column
cols = conn.execute("PRAGMA table_info(shows)").map { |r| r[1] }
unless cols.include?("opml_import")
conn.execute("ALTER TABLE shows ADD COLUMN opml_import INTEGER DEFAULT 0")
end
conn.commit rescue nil
conn
end
def sanitize_slug(name)
slug = name.downcase.strip
slug = slug.gsub(/[^a-z0-9\-_]/, "_")
slug = slug.split("_").reject(&:empty?).join("_")
slug[0, 80]
end
# ---- Registration from OPML --------------------------------------------
def register_shows_from_opml_xml(xml_bytes, opml_import: false)
doc = Nokogiri::XML(xml_bytes)
added = updated_flag = skipped = 0
doc.xpath("//outline").each do |node|
xml_url = node["xmlUrl"].to_s
next if xml_url.empty?
otype = node["type"].to_s
next unless otype.empty? || otype == "rss"
show_name = node["text"] || node["title"] || "Unknown Show"
slug = sanitize_slug(show_name)
flag = opml_import ? 1 : 0
existing = db.get_first_row("SELECT id, opml_import FROM shows WHERE feed_url=?", xml_url)
if existing
existing_id, existing_flag = existing
if flag == 1 && existing_flag.to_i == 0
db.execute("UPDATE shows SET opml_import=1 WHERE id=?", existing_id)
updated_flag += 1
puts " Protected existing: #{show_name} (#{slug})"
else
skipped += 1
end
else
db.execute(
"INSERT INTO shows (name, feed_url, slug, opml_import) VALUES (?, ?, ?, ?)",
[show_name, xml_url, slug, flag]
)
added += 1
puts " Registered: #{show_name} -> #{slug}"
end
end
db.commit
puts " Added #{added} new show(s)." if added.positive?
puts " Upgraded #{updated_flag} show(s) to protected." if updated_flag.positive?
puts " Skipped #{skipped} duplicate(s)." if skipped.positive?
added
end
def import_opml_file(filepath)
unless File.exist?(filepath)
puts "ERROR: File not found: #{filepath}"
exit 1
end
xml_data = File.binread(filepath)
count = register_shows_from_opml_xml(xml_data, opml_import: true)
total = db.get_first_row("SELECT COUNT(*) FROM shows")[0]
puts "\nImport complete. #{count} new show(s) added. Total registered: #{total}"
ensure
db&.close
end
# ---- gPodder.net sync ----------------------------------------------------
def sync_gpoddernet
cfg = load_config
gp = cfg.fetch("gpodder", {})
unless gp.fetch("enable", false)
puts "gpodder.net sync is disabled in config.json."
return
end
unless gp["username"] && gp["password"]
puts "ERROR: gpodder.username/gpodder.password not set in config.json"
exit 1
end
url = "#{gp['host']}/subscriptions/#{gp['username']}.opml"
puts "Fetching subscriptions from #{gp['host']} for '#{gp['username']}'..."
uri = URI(url)
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
req = Net::HTTP::Get.new(uri)
req.basic_auth(gp["username"], gp["password"])
http.request(req)
end
if resp.code == "401"
puts "ERROR: Authentication failed. Check username/password in config.json."
exit 1
elsif resp.code != "200"
puts "ERROR: Unexpected response #{resp.code}: #{resp.body[0, 200]}"
exit 1
end
# Collect all feed URLs from the current subscription list
root_doc = Nokogiri::XML(resp.body)
gp_feed_urls = Set.new
root_doc.xpath("//outline").each do |node|
fu = node["xmlUrl"].to_s
gp_feed_urls.add(fu) unless fu.empty?
end
count = register_shows_from_opml_xml(resp.body, opml_import: false)
total = db.get_first_row("SELECT COUNT(*) FROM shows")[0]
prune_removed_shows(gp_feed_urls)
puts "\nSync complete. #{count} new, total registered: #{total}"
end
def prune_removed_shows(gp_feed_urls)
rows = db.execute("SELECT slug, feed_url, name FROM shows WHERE opml_import = 0")
to_remove = rows.select { |_slug, feed_url, _name| !gp_feed_urls.include?(feed_url) }
return if to_remove.empty?
to_remove.each do |slug, _feed_url, name|
puts " Removing unsubscribed show: #{name} (#{slug})"
db.execute("DELETE FROM seen WHERE show_slug=?", slug)
db.execute("DELETE FROM shows WHERE slug=?", slug)
show_dir = DOWNLOAD_DIR.join(slug)
if Dir.exist?(show_dir)
FileUtils.rm_rf(show_dir)
puts " Deleted directory: #{show_dir}"
end
end
db.commit
puts " Pruned #{to_remove.size} removed show(s)."
end
# ---- Manual management ---------------------------------------------------
def delete_show(slug)
row = db.get_first_row("SELECT name FROM shows WHERE slug=?", slug)
if row.nil?
puts "No show found with slug '#{slug}'."
return
end
name = row[0]
puts "Deleting show: #{name} (#{slug})"
db.execute("DELETE FROM seen WHERE show_slug=?", slug)
db.execute("DELETE FROM shows WHERE slug=?", slug)
db.commit
show_dir = DOWNLOAD_DIR.join(slug)
if Dir.exist?(show_dir)
FileUtils.rm_rf(show_dir)
puts "Deleted directory: #{show_dir}"
end
pls_file = AUDIO_ROOT.join("playlists", "#{slug}.pls")
if File.exist?(pls_file)
File.delete(pls_file)
puts "Deleted playlist: #{pls_file}"
end
puts "Done."
end
def add_show(feed_url)
puts "Fetching feed: #{feed_url}"
parsed = parse_feed(feed_url)
if parsed.nil?
puts "ERROR: Invalid or unreachable feed."
exit 1
end
show_name, entries = parsed
slug = sanitize_slug(show_name)
puts " Title: #{show_name}"
puts " Slug: #{slug}"
puts " Entries found: #{entries.size}"
existing = db.get_first_row("SELECT name FROM shows WHERE feed_url=?", feed_url)
if existing
puts "NOTE: Feed already registered as '#{existing[0]}'. Nothing to do."
return
end
db.execute(
"INSERT INTO shows (name, feed_url, slug, opml_import) VALUES (?, ?, ?, 1)",
[show_name, feed_url, slug]
)
db.commit
puts " Registered: #{show_name} -> #{slug}"
puts "\n--- Fetching episodes ---"
fetch_feed(show_name, feed_url, slug)
puts "\nDone. Episodes saved to: #{DOWNLOAD_DIR.join(slug)}/"
end
# ---- Core fetching ---------------------------------------------------------
def parse_feed(feed_url)
body = http_get(feed_url)
return nil if body.nil?
doc = Nokogiri::XML(body)
doc.remove_namespaces!
# Support both RSS (<channel>) and Atom (<feed>)
channel = doc.at_xpath("//channel") || doc.at_xpath("//feed")
return nil if channel.nil?
title = (channel.at_xpath("./title")&.text.presence || "Unknown Show")
entries = []
if doc.root.name == "rss"
doc.xpath("//item").each do |item|
enc = item.at_xpath(".//enclosure")
next if enc.nil?
entries << {
url: enc["url"].to_s,
type: enc["type"].to_s,
length: enc["length"].to_s,
title: (item.at_xpath("./title")&.text.presence || "untitled")
}
end
else
doc.xpath("//entry").each do |entry|
link = entry.at_xpath("./link[@rel='enclosure']") ||
entry.at_xpath("./link")
next if link.nil?
dur = entry.at_xpath(".//media:duration")
entries << {
url: link["href"].to_s,
type: link["type"].to_s,
length: dur ? dur.text : "",
title: (entry.at_xpath("./title")&.text.presence || "untitled")
}
end
end
[title, entries]
rescue StandardError => e
warn "Feed parse error for #{feed_url}: #{e.message}"
def self.append_log(filename, msg)
FileUtils.mkdir_p(LOGS_DIR)
File.open(File.join(LOGS_DIR, filename), "a") { |f| f.puts msg }
rescue StandardError
nil
end
def http_get(url, timeout: 120)
uri = URI(url)
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https",
open_timeout: 30, read_timeout: timeout) do |http|
res = http.request(Net::HTTP::Get.new(uri))
res.code == "200" ? res.body : nil
end
def self.load_config
JSON.parse(File.read(CONFIG_PATH))
end
def fetch_feed(show_name, feed_url, slug)
parsed = parse_feed(feed_url)
if parsed.nil?
puts "[#{show_name}] ERROR: Could not parse feed."
return
def self.slugify(name)
s = name.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
s[0, 60] || "show"
end
_title, entries = parsed
show_dir = DOWNLOAD_DIR.join(slug)
FileUtils.mkdir_p(show_dir)
new_count = 0
entries.each do |ep|
ep_url = ep[:url]
title = ep[:title]
next if ep_url.empty?
row = db.get_first_row("SELECT 1 FROM seen WHERE url=?", ep_url)
next if row
duration_sec = parse_duration(ep[:length])
safe_title = title.gsub(/[^\w\- ]/, "_").strip[0, 120]
ext = extension_for_type(ep[:type])
filepath = show_dir.join("#{safe_title}#{ext}")
if File.exist?(filepath)
db.execute(
"INSERT OR IGNORE INTO seen (url, title, show_slug, duration_sec) VALUES (?, ?, ?, ?)",
[ep_url, title, slug, duration_sec]
# ---------------------------------------------------------
# Database
# ---------------------------------------------------------
def self.open_subs_db
db = SQLite3::Database.new(SUBS_DB)
db.results_as_hash = true
db.execute <<-SQL
CREATE TABLE IF NOT EXISTS shows (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
feed_url TEXT NOT NULL UNIQUE,
source TEXT DEFAULT 'manual',
opml_import INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)
db.commit
next
SQL
db
end
begin
puts "[#{show_name}] Downloading: #{title}"
data = http_get(ep_url)
if data.nil?
raise "download returned non-200"
end
File.binwrite(filepath, data)
db.execute(
"INSERT OR IGNORE INTO seen (url, title, show_slug, duration_sec) VALUES (?, ?, ?, ?)",
[ep_url, title, slug, duration_sec]
def self.open_played_db
db = SQLite3::Database.new(PLAYED_DB)
db.results_as_hash = true
db.execute <<-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,
duration_seconds INTEGER,
played_at TEXT,
UNIQUE(show_slug, guid)
)
db.commit
new_count += 1
SQL
db
end
# ---------------------------------------------------------
# gPodder.net sync
# ---------------------------------------------------------
def self.gpodder_sync(config)
g = config["gpodder"]
base = g["host"].chomp("/")
username = g["username"]
password = g["password"]
url = "#{base}/subscriptions/#{CGI.escape(username)}.opml"
puts "--- Syncing subscriptions from #{base} ---"
puts "Fetching subscriptions for '#{username}'..."
uri = URI.parse(url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == "https")
http.open_timeout = 30
http.read_timeout = 60
req = Net::HTTP::Get.new(uri)
req.basic_auth(username, password)
req["Accept"] = "application/x-opml, text/xml, */*"
req["User-Agent"] = "radio-automation/1.0"
resp = http.request(req)
case resp.code
when "200"
body = resp.body
if body.nil? || body.empty?
log_error("gPodder sync returned an empty body.")
return []
end
parse_opml(body)
when "401"
log_error("gPodder sync failed: 401 Unauthorized. Check username/password in config.json.")
[]
when "404"
log_error("gPodder sync failed: 404 Not Found. User may not exist or has no subscriptions.")
[]
when "400"
log_error("gPodder sync failed: 400 Bad Request.")
[]
else
log_error("gPodder sync failed: unexpected response #{resp.code}: #{resp.body.to_s[0..200]}")
[]
end
end
def self.parse_opml(xml_string)
doc = REXML::Document.new(xml_string)
shows = []
REXML::XPath.each(doc, "//outline[@xmlUrl]") do |node|
feed_url = node.attributes["xmlUrl"].to_s.strip
name = node.attributes["text"].to_s.strip
next unless feed_url =~ /\Ahttps?:\/\//
shows << { "name" => name, "feed_url" => feed_url }
end
shows
rescue REXML::ParseException => e
log_error("Failed to parse OPML XML: #{e.message}")
[]
end
def self.register_remote_shows(remote_shows)
db = open_subs_db
added = 0
remote_shows.each do |show|
slug = slugify(show["name"])
existing = db.get_first_value("SELECT slug FROM shows WHERE slug = ?", slug)
if existing.nil?
db.execute(
"INSERT INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'gpodder', 0)",
[slug, show["name"], show["feed_url"]]
)
log_info("Registered new show: #{show['name']} (#{slug})")
added += 1
end
end
db.close
added
end
def self.prune_stale_shows(remote_shows)
db = open_subs_db
remote_slugs = remote_shows.map { |s| slugify(s["name"]) }
stale = db.query_all("SELECT slug, name FROM shows WHERE source = 'gpodder' AND opml_import = 0")
removed = 0
stale.each do |row|
next if remote_slugs.include?(row["slug"])
remove_show_data(row["slug"])
db.execute("DELETE FROM shows WHERE slug = ?", [row["slug"]])
log_info("Pruned stale show: #{row['name']} (#{row['slug']})")
removed += 1
end
db.close
removed
end
# ---------------------------------------------------------
# Feed parsing and download
# ---------------------------------------------------------
def self.extract_duration(entry_xml)
# Try media:duration first
if (m = entry_xml.match(/media:duration[^>]*content="([^"]+)"/))
val = m[1]
return val.to_i if val =~ /\A\d+\z/
if (iso = val.match(/\APT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/))
h, mn, s = iso.captures.compact.map(&:to_i)
return (h || 0) * 3600 + (mn || 0) * 60 + (s || 0)
end
end
# Fall back to enclosure length (bytes) -> rough seconds at 128kbps
if (m = entry_xml.match(/enclosure[^>]*length="(\d+)"/))
bytes = m[1].to_i
return (bytes * 8 / 128_000) if bytes > 0
end
nil
end
def self.fetch_feed(feed_url)
uri = URI.parse(feed_url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == "https")
http.open_timeout = 30
http.read_timeout = 60
req = Net::HTTP::Get.new(uri)
req["User-Agent"] = "radio-automation/1.0"
resp = http.request(req)
raise "Feed HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
resp.body
rescue StandardError => e
puts "[#{show_name}] FAILED to download '#{title}': #{e.message}"
log_error("Feed fetch error for #{feed_url}: #{e.message}")
nil
end
def self.download_episode(url, dest_dir, filename)
FileUtils.mkdir_p(dest_dir)
dest = File.join(dest_dir, filename)
return dest if File.exist?(dest)
uri = URI.parse(url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == "https")
http.open_timeout = 30
http.read_timeout = 120
req = Net::HTTP::Get.new(uri)
req["User-Agent"] = "radio-automation/1.0"
tmp = "#{dest}.part"
begin
http.request(req) do |resp|
raise "Download HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
File.open(tmp, "wb") do |f|
resp.read_body { |chunk| f.write(chunk) }
end
end
File.rename(tmp, dest)
dest
rescue StandardError => e
log_error("Download failed for #{url}: #{e.message}")
File.delete(tmp) if File.exist?(tmp)
nil
end
end
if new_count.positive?
puts "[#{show_name}] Downloaded #{new_count} new episode(s)."
else
puts "[#{show_name}] No new episodes."
end
def self.safe_filename(title, fallback)
name = title.to_s.gsub(/[^\w\s.\-]/, "").strip.tr(" ", "_")[0, 120]
"#{name || fallback}.mp3"
end
def parse_duration(raw)
raw = raw.to_s.strip
return 0 if raw.empty?
if raw.include?(":")
raw.split(":").last.to_i
else
raw.to_i
end
rescue StandardError
0
def self.fetch_show_episodes(slug, name, feed_url)
dest_dir = File.join(PODCASTS_DIR, slug)
raw = fetch_feed(feed_url)
return 0 if raw.nil?
played_db = open_played_db
seen = played_db.query("SELECT guid FROM episodes WHERE show_slug = ?", slug).map { |r| r["guid"] }
subs_db = open_subs_db
new_count = 0
# Simple regex-based RSS/Atom entry extraction
raw.scan(/<(?:item|entry)[^>]*>(.*?)<\/(?:item|entry)>/mi).each do |(entry_xml)|
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])
next if seen.include?(guid)
enc_m = entry_xml.match(/enclosure[^>]*url="([^"]+)"/i)
next unless enc_m
audio_url = enc_m[1]
title_m = entry_xml.match(/<title[^>]*>([^<]*)<\/title>/i)
title = title_m ? title_m[1].strip : "untitled"
filename = safe_filename(title, guid[-20..])
file_path = download_episode(audio_url, dest_dir, filename)
next if file_path.nil?
duration = extract_duration(entry_xml)
played_db.execute(
"INSERT OR IGNORE INTO episodes (show_slug, guid, title, file_path, duration_seconds, played_at) VALUES (?, ?, ?, ?, ?, NULL)",
[slug, guid, title, file_path, duration]
)
new_count += 1
log_info(" New episode: #{title} [#{filename}]")
end
def extension_for_type(type_str)
t = type_str.to_s.downcase
".ogg" if t.include?("ogg")
".m4a" if t.include?("m4a") || t.include?("aac")
".mp3"
played_db.close
subs_db.close
new_count
end
def list_shows(detail: false)
rows = db.execute("SELECT name, slug, feed_url, opml_import FROM shows ORDER BY name")
def self.fetch_all_episodes
db = open_subs_db
shows = db.query_all("SELECT slug, name, feed_url FROM shows ORDER BY name")
db.close
total_new = 0
shows.each do |show|
log_info("--- Fetching: #{show['name']} (#{show['slug']}) ---")
begin
total_new += fetch_show_episodes(show["slug"], show["name"], show["feed_url"])
rescue StandardError => e
log_error("Unexpected error fetching #{show['slug']}: #{e.message}")
end
end
log_info("=== Fetch complete: #{total_new} new episode(s) ===")
end
# ---------------------------------------------------------
# Admin operations
# ---------------------------------------------------------
def self.list_shows(detail: false)
db = open_subs_db
rows = db.query_all("SELECT slug, name, feed_url, source, opml_import FROM shows ORDER BY name")
db.close
if rows.empty?
puts "No shows registered."
return
end
puts format("%-40s %-30s %-10s", "Show Name", "Slug", "Protected")
puts "-" * 80
rows.each do |name, slug, _feed_url, prot|
puts format("%-40s %-30s %-10s", name, slug, prot.to_i == 1 ? "yes" : "no")
end
if detail
puts "\nFeed URLs:"
rows.each do |_name, slug, feed_url, _prot|
puts " #{slug}: #{feed_url}"
end
puts format("%-30s %-10s %s", "SLUG", "PROTECTED", "NAME")
rows.each do |r|
prot = r["opml_import"] ? "yes" : "no"
line = format("%-30s %-10s %s", r["slug"], prot, r["name"])
line += "\n" + (" " * 50) + r["feed_url"] if detail
puts line
end
end
def close
db&.close
def self.add_show(feed_url)
raw = fetch_feed(feed_url)
if raw.nil?
log_error("Could not fetch feed: #{feed_url}")
return
end
title_m = raw.match(/<channel[^>]*>\s*<title[^>]*>([^<]*)<\/title>/im) ||
raw.match(/<feed[^>]*>\s*<title[^>]*>([^<]*)<\/title>/im)
if title_m.nil?
log_error("Could not determine show title from #{feed_url}")
return
end
name = title_m[1].strip
slug = slugify(name)
db = open_subs_db
db.execute(
"INSERT OR IGNORE INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'manual', 0)",
[slug, name, feed_url]
)
db.close
log_info("Added show: #{name} (#{slug})")
fetch_show_episodes(slug, name, feed_url)
end
require "set"
def self.remove_show_data(slug)
pod_dir = File.join(PODCASTS_DIR, slug)
FileUtils.rm_rf(pod_dir) if Dir.exist?(pod_dir)
pls = File.join(ROOT, "playlists", "#{slug}.pls")
File.delete(pls) if File.exist?(pls)
end
def main
args = ARGV.dup
f = Radio::Fetcher.new
def self.delete_show(slug)
db = open_subs_db
row = db.get_first_hash("SELECT name FROM shows WHERE slug = ?", slug)
if row.nil?
log_error("No show found with slug '#{slug}'.")
return
end
remove_show_data(slug)
db.execute("DELETE FROM shows WHERE slug = ?", [slug])
db.close
played_db = open_played_db
played_db.execute("DELETE FROM episodes WHERE show_slug = ?", [slug])
played_db.close
log_info("Deleted show: #{row['name']} (#{slug})")
end
if args.include?("--delete-show")
idx = args.index("--delete-show")
val = args[idx + 1]
if val.nil?
puts "Usage: fetch_podcasts.rb --delete-show <slug>"
exit 1
def self.import_opml(path)
content = File.read(path)
shows = parse_opml(content)
db = open_subs_db
shows.each do |show|
slug = slugify(show["name"])
db.execute(
"INSERT OR IGNORE INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'opml', 1)",
[slug, show["name"], show["feed_url"]]
)
end
f.delete_show(val)
elsif args.include?("--add-show")
idx = args.index("--add-show")
val = args[idx + 1]
if val.nil?
puts "Usage: fetch_podcasts.rb --add-show <feed-url>"
exit 1
db.close
log_info("OPML import: #{shows.size} show(s) processed.")
end
f.add_show(val)
elsif args.include?("--import-opml")
idx = args.index("--import-opml")
val = args[idx + 1]
if val.nil?
puts "Usage: fetch_podcasts.rb --import-opml <path-to-file.opml>"
exit 1
end
f.import_opml_file(val)
elsif args.include?("--list-shows")
f.list_shows(detail: args.include?("--detail"))
# ---------------------------------------------------------
# Main flow
# ---------------------------------------------------------
def self.run_fetch(config)
g = config["gpodder"]
if g["enable"] == true
remote = gpodder_sync(config)
if remote.empty?
log_info("No subscriptions retrieved from gPodder; using local registry only.")
else
cfg = f.load_config
gp = cfg.fetch("gpodder", {})
if gp.fetch("enable", false)
puts "--- Syncing subscriptions from gpodder.net ---"
begin
f.sync_gpoddernet
rescue SystemExit
raise
rescue StandardError => e
puts "! gpodder sync failed (continuing with existing shows): #{e.message}"
added = register_remote_shows(remote)
pruned = prune_stale_shows(remote)
log_info("Sync: #{added} added, #{pruned} pruned.")
end
end
fetch_all_episodes
end
shows = f.db.execute("SELECT name, feed_url, slug FROM shows")
if shows.empty?
puts "No shows registered. Import an OPML file, add a show, or enable gpodder sync."
def self.main
options = {}
OptionParser.new do |opts|
opts.banner = "Usage: fetch_podcasts.rb [options]"
opts.on("--list-shows", "List registered shows") { options[:list] = true }
opts.on("--detail", "With --list-shows, show feed URLs") { options[:detail] = true }
opts.on("--add-show FEED_URL", "Add a show from a feed URL") { |v| options[:add] = v }
opts.on("--delete-show SLUG", "Delete a show and its data") { |v| options[:delete] = v }
opts.on("--import-opml FILE", "Import shows from an OPML file") { |v| options[:import] = v }
end.parse!
FileUtils.mkdir_p(STATE_DIR)
FileUtils.mkdir_p(LOGS_DIR)
FileUtils.mkdir_p(PODCASTS_DIR)
config = load_config
if options[:list]
list_shows(detail: options[:detail])
elsif options[:add]
add_show(options[:add])
elsif options[:delete]
delete_show(options[:delete])
elsif options[:import]
import_opml(options[:import])
else
puts "--- Fetching episodes for #{shows.size} show(s) ---"
shows.each do |show_name, feed_url, slug|
begin
f.fetch_feed(show_name, feed_url, slug)
rescue StandardError => e
puts "[#{show_name}] FAILED: #{e.message}"
run_fetch(config)
end
end
end
end
ensure
f&.close
end
main
RadioAutomation.main

View file

@ -1,580 +1,200 @@
#!/bin/bash
#
# install_for_jruby - Setup for liquidsoap radio automation stack (JRuby edition)
# Target: Linux Mint 22.3 (Ubuntu 24.04 Noble based)
#
# Usage: sudo ./install_for_jruby
# Must be run from within the target directory (e.g., /srv/audio/)
#
# Detects existing Java/JRuby; installs OpenJDK 21 and JRuby 10.1.1.0 into /opt
# only if missing, integrating JRuby via update-alternatives. Installs gems one
# at a time with a raised JVM heap to avoid OOM on low-RAM hosts.
#
#!/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.
# Must be run as root from within the target directory (e.g. /srv/radio/).
# Idempotent: safe to re-run.
set -euo pipefail
# -------------------------------------------------------
# Determine install directory from script location
# -------------------------------------------------------
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
INSTALL_DIR="$(dirname "$SCRIPT_PATH")"
INSTALL_DIR="$(pwd)"
SERVICE_NAME="$(basename "$INSTALL_DIR")"
LIQUIDSOAP_USER="liquidsoap"
JRUBY_VERSION="10.1.1.0"
JRUBY_HOME="/opt/jruby-${JRUBY_VERSION}"
GEM_HOME_LOCAL="$INSTALL_DIR/.gems"
JRBURY_VERSION="10.1.1.0"
MAVEN_BASE="https://repo1.maven.org/maven2/org/jruby/jruby-dist"
JRUBY_HOME_PINNED="/opt/jruby-${JRBURY_VERSION}"
echo "=== Radio Automation Installer (JRuby) ==="
echo "Install directory: $INSTALL_DIR"
echo "Install dir: $INSTALL_DIR"
echo "Service name: $SERVICE_NAME"
echo ""
echo
# -------------------------------------------------------
# 0. Validate environment
# -------------------------------------------------------
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: This script must be run as root (sudo)."
exit 1
# --- Prompt for config values (pre-fill from existing config.json) ---
EXISTING_CONFIG="$INSTALL_DIR/config.json"
declare -A CFG
CFG[ICE_HOST]="localhost"
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
echo "Existing config.json found; using as defaults."
CFG[ICE_HOST]=$(jq -r '.icecast.host // "localhost"' "$EXISTING_CONFIG")
CFG[ICE_PORT]=$(jq -r '.icecast.port // 7777' "$EXISTING_CONFIG")
CFG[ICE_MOUNT]=$(jq -r '.icecast.mount // "/audio.mp3"' "$EXISTING_CONFIG")
CFG[ICE_USER]=$(jq -r '.icecast.username // "source"' "$EXISTING_CONFIG")
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
if [ ! -d "$INSTALL_DIR" ]; then
echo "ERROR: Install directory $INSTALL_DIR does not exist."
exit 1
fi
read -rp "Icecast host [$CFG[ICE_HOST]]: " v; CFG[ICE_HOST]=${v:-$CFG[ICE_HOST]}
read -rp "Icecast port [$CFG[ICE_PORT]]: " v; CFG[ICE_PORT]=${v:-$CFG[ICE_PORT]}
read -rp "Icecast mount [$CFG[ICE_MOUNT]]: " v; CFG[ICE_MOUNT]=${v:-$CFG[ICE_MOUNT]}
read -rp "Icecast source username [$CFG[ICE_USER]]: " v; CFG[ICE_USER]=${v:-$CFG[ICE_USER]}
read -rsp "Icecast source password [$CFG[ICE_PASS]]: "; echo; CFG[ICE_PASS]=${v:-$CFG[ICE_PASS]}
read -rp "Enable gPodder sync? (true/false) [$CFG[GPODDER_ENABLE]]: " v; CFG[GPODDER_ENABLE]=${v:-$CFG[GPODDER_ENABLE]}
read -rp "gPodder host [$CFG[GPODDER_HOST]]: " v; CFG[GPODDER_HOST]=${v:-$CFG[GPODDER_HOST]}
read -rp "gPodder username [$CFG[GPODDER_USER]]: " v; CFG[GPODDER_USER]=${v:-$CFG[GPODDER_USER]}
read -rsp "gPodder password [$CFG[GPODDER_PASS]]: "; echo; CFG[GPODDER_PASS]=${v:-$CFG[GPODDER_PASS]}
if [ ! -w "$INSTALL_DIR" ]; then
echo "ERROR: Cannot write to $INSTALL_DIR. Check permissions."
exit 1
fi
echo
echo "Summary:"
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; }
REQUIRED_DIRS=(
"music"
"podcasts"
"jingles"
"announcements"
"playlists"
"state"
"logs"
)
# --- Base packages ---
echo "Installing base packages..."
apt-get update -qq
apt-get install -y -qq liquidsoap icecast2 jq curl unzip ca-certificates >/dev/null
# -------------------------------------------------------
# Helper: prompt with default value
# -------------------------------------------------------
prompt() {
local var_name="$1"
local prompt_text="$2"
local default_value="${3:-}"
local hide_input="${4:-false}"
if [ -n "$default_value" ]; then
read -rp "$prompt_text [$default_value]: " input
if [ -z "$input" ]; then
eval "$var_name=$default_value"
else
eval "$var_name=\$input"
fi
else
if [ "$hide_input" = "true" ]; then
read -rsp "$prompt_text: " input
echo ""
eval "$var_name=\$input"
else
read -rp "$prompt_text: " input
eval "$var_name=\$input"
fi
fi
}
prompt_confirm() {
local var_name="$1"
local prompt_text="$2"
local default_value="${3:-no}"
read -rp "$prompt_text [$default_value]: " input
if [ -z "$input" ]; then
input="$default_value"
fi
case "$input" in
[Yy]* ) eval "$var_name=true" ;;
* ) eval "$var_name=false" ;;
esac
}
# -------------------------------------------------------
# 1. Interactive configuration
# -------------------------------------------------------
CONFIG_FILE="$INSTALL_DIR/config.json"
EXISTING_CONFIG=false
if [ -f "$CONFIG_FILE" ]; then
EXISTING_CONFIG=true
echo "--- Loading existing config.json for defaults ---"
ICECAST_HOST_DEFAULT=$(jq -r '.icecast.host // "localhost"' "$CONFIG_FILE")
ICECAST_PORT_DEFAULT=$(jq -r '.icecast.port // 7777' "$CONFIG_FILE")
ICECAST_MOUNT_DEFAULT=$(jq -r '.icecast.mount // "/audio.mp3"' "$CONFIG_FILE")
ICECAST_USERNAME_DEFAULT=$(jq -r '.icecast.username // "source"' "$CONFIG_FILE")
GPODDER_ENABLE_DEFAULT=$(jq -r 'if .gpodder.enable == true then "yes" else "no" end' "$CONFIG_FILE")
GPODDER_HOST_DEFAULT=$(jq -r '.gpodder.host // "https://gpodder.net"' "$CONFIG_FILE")
GPODDER_USERNAME_DEFAULT=$(jq -r '.gpodder.username // ""' "$CONFIG_FILE")
else
ICECAST_HOST_DEFAULT="localhost"
ICECAST_PORT_DEFAULT="7777"
ICECAST_MOUNT_DEFAULT="/audio.mp3"
ICECAST_USERNAME_DEFAULT="source"
GPODDER_ENABLE_DEFAULT="no"
GPODDER_HOST_DEFAULT="https://gpodder.net"
GPODDER_USERNAME_DEFAULT=""
fi
echo ""
echo "--- Icecast Settings ---"
echo ""
ICECAST_HOST=""
ICECAST_PORT=""
ICECAST_MOUNT=""
ICECAST_USERNAME=""
ICECAST_PASSWORD=""
while true; do
prompt ICECAST_HOST "Icecast host" "$ICECAST_HOST_DEFAULT"
[[ "$ICECAST_HOST" =~ ^[a-zA-Z0-9._-]+$ ]] && break
echo "Invalid hostname. Try again."
done
while true; do
prompt ICECAST_PORT "Source port" "$ICECAST_PORT_DEFAULT"
[[ "$ICECAST_PORT" =~ ^[0-9]{1,5}$ ]] && (( ICECAST_PORT >= 1 )) && (( ICECAST_PORT <= 65535 )) && break
echo "Port must be a number between 1 and 65535. Try again."
done
while true; do
prompt ICECAST_MOUNT "Mount point" "$ICECAST_MOUNT_DEFAULT"
[[ "$ICECAST_MOUNT" == /* ]] && break
echo "Mount point must start with /. Try again."
done
prompt ICECAST_USERNAME "Source username" "$ICECAST_USERNAME_DEFAULT"
while true; do
prompt ICECAST_PASSWORD "Source password" "" "true"
[ -n "$ICECAST_PASSWORD" ] && break
echo "Password cannot be empty. Try again."
done
echo ""
echo "--- gPodder.net Sync Settings ---"
echo ""
GPODDER_ENABLE=false
if [ "$GPODDER_ENABLE_DEFAULT" = "yes" ]; then
prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "yes"
else
prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "no"
fi
GPODDER_HOST=""
GPODDER_USERNAME=""
GPODDER_PASSWORD=""
if [ "$GPODDER_ENABLE" = "true" ]; then
prompt GPODDER_HOST "gPodder host" "$GPODDER_HOST_DEFAULT"
while true; do
prompt GPODDER_USERNAME "gPodder username/email" "$GPODDER_USERNAME_DEFAULT"
[ -n "$GPODDER_USERNAME" ] && break
echo "Username cannot be empty. Try again."
done
while true; do
prompt GPODDER_PASSWORD "gPodder password" "" "true"
[ -n "$GPODDER_PASSWORD" ] && break
echo "Password cannot be empty. Try again."
done
fi
echo ""
echo "--- Summary ---"
echo " Icecast: ${ICECAST_HOST}:${ICECAST_PORT}${ICECAST_MOUNT} (user: ${ICECAST_USERNAME})"
echo " gPodder: $( [ "$GPODDER_ENABLE" = "true" ] && echo "enabled (${GPODDER_USERNAME}@${GPODDER_HOST})" || echo "disabled" )"
echo ""
CONFIRM_INSTALL=false
prompt_confirm CONFIRM_INSTALL "Proceed with these settings?" "yes"
if [ "$CONFIRM_INSTALL" != "true" ]; then
echo "Aborted by user."
exit 0
fi
# -------------------------------------------------------
# 2. Base system packages (always needed regardless of Java state)
# -------------------------------------------------------
echo ""
echo "--- Installing base system packages ---"
apt-get update
apt-get install -y \
liquidsoap \
icecast2 \
jq \
curl \
ca-certificates \
unzip
# -------------------------------------------------------
# 3. Detect / install Java (OpenJDK 21 headless)
# -------------------------------------------------------
echo ""
echo "--- Checking Java ---"
# --- Detect or install Java (>= 21) ---
JAVA_OK=false
if command -v java >/dev/null 2>&1; then
JAVA_VER_LINE=$(java -version 2>&1 | head -1)
# Extract major version: handles both '"21.0.x"' and '1.8.0_xxx' styles
JAVA_MAJOR=$(echo "$JAVA_VER_LINE" | sed -nE 's/.*"([0-9]+)(\.[0-9]+)?".*/\1/p')
if [ -n "$JAVA_MAJOR" ] && [ "$JAVA_MAJOR" -ge 21 ]; then
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
echo " Found suitable Java ($JAVA_VER_LINE)"
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 " Java found but version too old ($JAVA_VER_LINE); installing OpenJDK 21."
fi
else
echo " No Java detected; installing OpenJDK 21."
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
if [ "$JAVA_OK" != "true" ]; then
apt-get install -y openjdk-21-jre-headless
echo " Installed OpenJDK 21."
fi
# -------------------------------------------------------
# 4. Detect / install JRuby
# -------------------------------------------------------
echo ""
echo "--- Checking JRuby ---"
JRUBY_FOUND=""
# Preferred: our pinned home already present
if [ -x "$JRUBY_HOME/bin/jruby" ]; then
JRUBY_FOUND="$JRUBY_HOME/bin/jruby"
echo " Found JRuby at $JRUBY_HOME"
fi
# Fallback: any jruby resolvable on PATH (system, other /opt version, etc.)
if [ -z "$JRUBY_FOUND" ] && command -v jruby >/dev/null 2>&1; then
DETECTED_JRUBY="$(command -v jruby)"
DETECTED_JRUBY="$(readlink -f "$DETECTED_JRUBY")"
JRUBY_FOUND="$DETECTED_JRUBY"
echo " Found existing JRuby on PATH: $DETECTED_JRUBY"
echo " ($( "$DETECTED_JRUBY" -v 2>/dev/null | head -1 ))"
fi
if [ -z "$JRUBY_FOUND" ]; then
echo " No JRuby detected; installing JRuby ${JRUBY_VERSION} to /opt ..."
JRUBY_TARBALL="/tmp/jruby-dist-${JRUBY_VERSION}-bin.tar.gz"
# Post-9.1.14.0 releases are distributed via Maven Central as jruby-dist-*
curl -fsSL "https://repo1.maven.org/maven2/org/jruby/jruby-dist/${JRUBY_VERSION}/jruby-dist-${JRUBY_VERSION}-bin.tar.gz" -o "$JRUBY_TARBALL"
mkdir -p /opt
tar -xzf "$JRUBY_TARBALL" -C /opt
rm -f "$JRUBY_TARBALL"
ln -sfn "$JRUBY_HOME" /opt/jruby-current
JRUBY_FOUND="$JRUBY_HOME/bin/jruby"
echo " Installed JRuby to $JRUBY_HOME"
fi
# Derive the effective JRuby home from whichever binary we ended up using
EFF_JRUBY_BIN="$JRUBY_FOUND"
EFF_JRUBY_HOME="$(dirname "$(dirname "$EFF_JRUBY_BIN")")"
export PATH="$EFF_JRUBY_HOME/bin:$PATH"
# -------------------------------------------------------
# 5. Integrate JRuby with the system via update-alternatives
# -------------------------------------------------------
echo "--- Integrating JRuby via update-alternatives ---"
update-alternatives --install /usr/local/bin/jruby jruby "$EFF_JRUBY_BIN" 100
# Also expose bundle/gem/rake under alternatives so they resolve system-wide
for tool in bundle gem rake irb; do
if [ -x "$EFF_JRUBY_HOME/bin/$tool" ]; then
update-alternatives --install "/usr/local/bin/$tool" "$tool" "$EFF_JRUBY_HOME/bin/$tool" 100
# --- 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 " Registered jruby -> $EFF_JRUBY_BIN (priority 100)"
echo "JRuby integrated via /usr/local/bin symlinks."
# Sanity-check that the integrated binary actually runs
if ! "$EFF_JRUBY_BIN" -v >/dev/null 2>&1; then
echo "ERROR: Integrated JRuby binary failed to execute. Aborting."
exit 1
fi
echo " Verified: $("${EFF_JRUBY_BIN}" -v 2>/dev/null | head -1)"
# -------------------------------------------------------
# 6. Install gems into a project-local gem home (one at a time)
# -------------------------------------------------------
echo "--- Installing Ruby gems ---"
# --- Install gems one at a time (avoid OOM on low-RAM hosts) ---
export GEM_HOME="$GEM_HOME_LOCAL"
export GEM_PATH="$GEM_HOME_LOCAL"
# Raise the JVM heap for gem operations. JRuby defaults to ~500MB, which is
# too small when bundler resolves multiple gems at once on a low-RAM host.
export JRUBY_OPTS="-J-Xmx1g -J-Xss512k"
install_gem() {
local name="$1"
echo " Installing gem: $name"
if ! "$EFF_JRUBY_BIN" -S gem install "$name" --no-document; then
echo "ERROR: Failed to install gem '$name'."
exit 1
fi
}
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
# Install bundler first (needed to drive bundle install), then each dependency
# separately so a single OOM can't take down the whole set.
install_gem bundler
install_gem nokogiri
install_gem sqlite3
install_gem json
# Lock versions against the Gemfile now that every gem is present.
(cd "$INSTALL_DIR" && \
GEM_HOME="$GEM_HOME_LOCAL" GEM_PATH="$GEM_HOME_LOCAL" \
JRUBY_OPTS="$JRUBY_OPTS" \
"$EFF_JRUBY_BIN" -S bundle install --quiet)
echo " Gems installed to $GEM_HOME_LOCAL"
# -------------------------------------------------------
# 7. Create and verify directory structure
# -------------------------------------------------------
echo "--- Creating directory structure ---"
MISSING=()
for dir in "${REQUIRED_DIRS[@]}"; do
full_path="$INSTALL_DIR/$dir"
if [ ! -d "$full_path" ]; then
mkdir -p "$full_path"
MISSING+=("$dir")
fi
# --- Create directory structure ---
for d in music podcasts jingles announcements playlists state logs; do
mkdir -p "$INSTALL_DIR/$d"
done
if [ ${#MISSING[@]} -gt 0 ]; then
echo " Created: ${MISSING[*]}"
else
echo " All directories already present."
fi
FAILED=()
for dir in "${REQUIRED_DIRS[@]}"; do
if [ ! -d "$INSTALL_DIR/$dir" ]; then
FAILED+=("$dir")
fi
done
if [ ${#FAILED[@]} -gt 0 ]; then
echo "ERROR: Failed to create directories: ${FAILED[*]}"
exit 1
fi
# -------------------------------------------------------
# 8. Generate config.json from collected settings
# -------------------------------------------------------
echo "--- Generating config.json ---"
cat > "$CONFIG_FILE" << EOF
# --- Generate config.json ---
cat > "$INSTALL_DIR/config.json" <<EOF
{
"icecast": {
"host": "${ICECAST_HOST}",
"port": ${ICECAST_PORT},
"mount": "${ICECAST_MOUNT}",
"username": "${ICECAST_USERNAME}",
"password": "${ICECAST_PASSWORD}"
"host": "${CFG[ICE_HOST]}",
"port": ${CFG[ICE_PORT]},
"mount": "${CFG[ICE_MOUNT]}",
"username": "${CFG[ICE_USER]}",
"password": "${CFG[ICE_PASS]}"
},
"gpodder": {
"enable": ${GPODDER_ENABLE},
"host": "${GPODDER_HOST}",
"username": "${GPODDER_USERNAME}",
"password": "${GPODDER_PASSWORD}"
"enable": ${CFG[GPODDER_ENABLE]},
"host": "${CFG[GPODDER_HOST]}",
"username": "${CFG[GPODDER_USER]}",
"password": "${CFG[GPODDER_PASS]}"
}
}
EOF
chmod 600 "$INSTALL_DIR/config.json"
chmod 600 "$CONFIG_FILE"
echo " Written: $CONFIG_FILE"
# -------------------------------------------------------
# 9. Create schedule.txt template (if not present)
# -------------------------------------------------------
SCHEDULE_FILE="$INSTALL_DIR/schedule.txt"
if [ ! -f "$SCHEDULE_FILE" ]; then
echo "--- Creating schedule.txt template ---"
cat > "$SCHEDULE_FILE" << 'EOF'
# Radio Schedule
# Format: min hour dom mon dow TYPE TARGET [RUNLENGTH_SECONDS]
# TYPE: show | stream
# RUNLENGTH required for stream entries, ignored for show entries
#
# Examples:
# 0 8 * * 2 show hardcore_history
# 0 6 * * 1 stream http://example.org:8000/live.mp3 3600
EOF
else
echo "--- schedule.txt already exists, skipping ---"
# --- Ensure liquidsoap user exists ---
if ! id liquidsoap &>/dev/null; then
useradd --system --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin liquidsoap
fi
chown -R liquidsoap:liquidsoap "$INSTALL_DIR"
# -------------------------------------------------------
# 10. Verify required project files exist
# -------------------------------------------------------
echo "--- Verifying project files ---"
PROJECT_FILES=("station.liq" "fetch_podcasts.rb" "update_playlists.rb" "Gemfile")
FILE_ERRORS=()
for f in "${PROJECT_FILES[@]}"; do
if [ ! -f "$INSTALL_DIR/$f" ]; then
FILE_ERRORS+=("$f")
else
echo " Found: $f"
fi
done
if [ ${#FILE_ERRORS[@]} -gt 0 ]; then
echo "WARNING: Missing expected files: ${FILE_ERRORS[*]}"
echo " The service will not start until these are in place."
fi
# -------------------------------------------------------
# 11. Create systemd service named after the directory
# -------------------------------------------------------
echo "--- Creating systemd service: ${SERVICE_NAME}.service ---"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF
# --- Write systemd service ---
cat > /etc/systemd/system/"$SERVICE_NAME".service <<EOF
[Unit]
Description=Liquidsoap radio automation (${SERVICE_NAME})
Description=Liquidsoap radio station ($SERVICE_NAME)
After=network.target icecast2.service
[Service]
Type=simple
User=${LIQUIDSOAP_USER}
WorkingDirectory=${INSTALL_DIR}
User=liquidsoap
WorkingDirectory=$INSTALL_DIR
Environment=GEM_HOME=${GEM_HOME_LOCAL}
Environment=GEM_PATH=${GEM_HOME_LOCAL}
ExecStart=/usr/bin/liquidsoap ${INSTALL_DIR}/station.liq
Environment=JRUBY_OPTS=-J-Xmx1g -J-Xss512k
ExecStart=/usr/bin/liquidsoap $INSTALL_DIR/station.liq
Restart=on-failure
RestartSec=5
StandardOutput=append:${INSTALL_DIR}/logs/liquidsoap.log
StandardError=append:${INSTALL_DIR}/logs/liquidsoap.log
[Install]
WantedBy=multi-user.target
EOF
# -------------------------------------------------------
# 12. Create cron jobs (hourly fetch + hourly playlist update)
# -------------------------------------------------------
echo "--- Setting up cron jobs ---"
# --- 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"
# Cron runs with a minimal PATH, so reference the absolute integrated path.
RUBY_RUN="cd $INSTALL_DIR && GEM_HOME=$GEM_HOME_LOCAL GEM_PATH=$GEM_HOME_LOCAL /usr/local/bin/jruby -S bundle exec"
FETCH_CRON="0 * * * * $RUBY_RUN fetch_podcasts.rb >> $INSTALL_DIR/logs/fetch.log 2>&1"
UPDATE_CRON="30 * * * * $RUBY_RUN update_playlists.rb >> $INSTALL_DIR/logs/update.log 2>&1"
EXISTING_CRON=$(crontab -l -u "$LIQUIDSOAP_USER" 2>/dev/null || true)
CLEANED_CRON=$(echo "$EXISTING_CRON" | grep -v "fetch_podcasts.rb\|update_playlists.rb" || true)
if ! echo "$EXISTING_CRON" | grep -q "fetch_podcasts.rb"; then
CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON
}$FETCH_CRON"
echo " Added: fetch_podcasts.rb (hourly)"
else
echo " Skipped: fetch_podcasts.rb job already exists"
fi
if ! echo "$EXISTING_CRON" | grep -q "update_playlists.rb"; then
CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON
}$UPDATE_CRON"
echo " Added: update_playlists.rb (hourly at :30)"
else
echo " Skipped: update_playlists.rb job already exists"
fi
echo "$CLEANED_CRON" | crontab -u "$LIQUIDSOAP_USER" -
# -------------------------------------------------------
# 13. Permissions
# -------------------------------------------------------
echo "--- Setting ownership and permissions ---"
id -u "$LIQUIDSOAP_USER" &>/dev/null || useradd --system --shell /usr/sbin/nologin "$LIQUIDSOAP_USER"
chown -R "${LIQUIDSOAP_USER}:${LIQUIDSOAP_USER}" "$INSTALL_DIR"
chmod 700 "$INSTALL_DIR/state"
chmod 700 "$INSTALL_DIR/logs"
(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 enable "$SERVICE_NAME"
# -------------------------------------------------------
# 14. Final verification
# -------------------------------------------------------
echo ""
echo "=== Final Verification ==="
ALL_OK=true
for dir in "${REQUIRED_DIRS[@]}"; do
if [ -d "$INSTALL_DIR/$dir" ] && [ -r "$INSTALL_DIR/$dir" ] && [ -w "$INSTALL_DIR/$dir" ]; then
printf " [OK] %-20s\n" "$dir/"
else
printf " [FAIL] %-20s (missing or inaccessible)\n" "$dir/"
ALL_OK=false
fi
done
if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then
echo " [OK] Service file: ${SERVICE_NAME}.service"
else
echo " [FAIL] Service file not created"
ALL_OK=false
fi
if [ -f "$CONFIG_FILE" ]; then
if jq empty "$CONFIG_FILE" 2>/dev/null; then
echo " [OK] Config: config.json (valid JSON)"
else
echo " [FAIL] Config: config.json (invalid JSON)"
ALL_OK=false
fi
else
echo " [FAIL] Config file missing"
ALL_OK=false
fi
if [ -f "$SCHEDULE_FILE" ]; then
echo " [OK] Schedule: schedule.txt"
else
echo " [FAIL] Schedule file missing"
ALL_OK=false
fi
if command -v java >/dev/null 2>&1; then
echo " [OK] Java: $(java -version 2>&1 | head -1)"
else
echo " [FAIL] Java not available"
ALL_OK=false
fi
if [ -x /usr/local/bin/jruby ] && /usr/local/bin/jruby -v >/dev/null 2>&1; then
echo " [OK] JRuby: $(/usr/local/bin/jruby -v 2>/dev/null | head -1)"
else
echo " [FAIL] Integrated JRuby (/usr/local/bin/jruby) not runnable"
ALL_OK=false
fi
echo ""
if [ "$ALL_OK" = true ]; then
echo "=== Installation Complete ==="
else
echo "=== Installation Finished With Errors ==="
echo "Review the [FAIL] items above before starting the service."
fi
echo ""
echo
echo "=== Installation complete ==="
echo "Next steps:"
echo " 1. Place your music in $INSTALL_DIR/music/"
echo " 2. Edit $SCHEDULE_FILE with your show/stream schedule"
echo " 3. Review/edit $INSTALL_DIR/station.liq"
echo " 4. Test: sudo systemctl start icecast2 && sudo systemctl start ${SERVICE_NAME}"
echo " 5. Check logs: tail -f $INSTALL_DIR/logs/liquidsoap.log"
echo ""
echo "Manage shows (run as the liquidsoap user):"
RB_PREFIX="sudo -u liquidsoap env GEM_HOME=$GEM_HOME_LOCAL GEM_PATH=$GEM_HOME_LOCAL /usr/local/bin/jruby -S bundle exec"
echo " List: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --list-shows --detail"
echo " Delete: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --delete-show <slug>"
echo " Import OPML: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --import-opml <file.opml>"
echo " Add show: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --add-show <feed-url>"
echo " JSON state: $RB_PREFIX $INSTALL_DIR/update_playlists.rb --json"
echo " 1. Drop MP3/M4A files into $INSTALL_DIR/music/"
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,459 +1,142 @@
#!/bin/bash
#
# install_for_python - Setup for liquidsoap radio automation stack
# Target: Linux Mint 22.3 (Ubuntu 24.04 Noble based)
#
# Usage: sudo ./install_for_python
# Must be run from within the target directory (e.g., /srv/audio/)
#
#!/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/).
# Idempotent: safe to re-run.
set -euo pipefail
# -------------------------------------------------------
# Determine install directory from script location
# -------------------------------------------------------
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
INSTALL_DIR="$(dirname "$SCRIPT_PATH")"
INSTALL_DIR="$(pwd)"
SERVICE_NAME="$(basename "$INSTALL_DIR")"
LIQUIDSOAP_USER="liquidsoap"
VENV="$INSTALL_DIR/.venv"
PYTHON_BIN="$VENV/bin/python"
echo "=== Radio Automation Installer ==="
echo "Install directory: $INSTALL_DIR"
echo "=== Radio Automation Installer (Python) ==="
echo "Install dir: $INSTALL_DIR"
echo "Service name: $SERVICE_NAME"
echo ""
echo
# -------------------------------------------------------
# 0. Validate environment
# -------------------------------------------------------
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: This script must be run as root (sudo)."
exit 1
# --- Prompt for config values (pre-fill from existing config.json) ---
EXISTING_CONFIG="$INSTALL_DIR/config.json"
declare -A CFG
CFG[ICE_HOST]="localhost"
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
echo "Existing config.json found; using as defaults."
CFG[ICE_HOST]=$(jq -r '.icecast.host // "localhost"' "$EXISTING_CONFIG")
CFG[ICE_PORT]=$(jq -r '.icecast.port // 7777' "$EXISTING_CONFIG")
CFG[ICE_MOUNT]=$(jq -r '.icecast.mount // "/audio.mp3"' "$EXISTING_CONFIG")
CFG[ICE_USER]=$(jq -r '.icecast.username // "source"' "$EXISTING_CONFIG")
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
if [ ! -d "$INSTALL_DIR" ]; then
echo "ERROR: Install directory $INSTALL_DIR does not exist."
exit 1
read -rp "Icecast host [$CFG[ICE_HOST]]: " v; CFG[ICE_HOST]=${v:-$CFG[ICE_HOST]}
read -rp "Icecast port [$CFG[ICE_PORT]]: " v; CFG[ICE_PORT]=${v:-$CFG[ICE_PORT]}
read -rp "Icecast mount [$CFG[ICE_MOUNT]]: " v; CFG[ICE_MOUNT]=${v:-$CFG[ICE_MOUNT]}
read -rp "Icecast source username [$CFG[ICE_USER]]: " v; CFG[ICE_USER]=${v:-$CFG[ICE_USER]}
read -rsp "Icecast source password [$CFG[ICE_PASS]]: "; echo; CFG[ICE_PASS]=${v:-$CFG[ICE_PASS]}
read -rp "Enable gPodder sync? (true/false) [$CFG[GPODDER_ENABLE]]: " v; CFG[GPODDER_ENABLE]=${v:-$CFG[GPODDER_ENABLE]}
read -rp "gPodder host [$CFG[GPODDER_HOST]]: " v; CFG[GPODDER_HOST]=${v:-$CFG[GPODDER_HOST]}
read -rp "gPodder username [$CFG[GPODDER_USER]]: " v; CFG[GPODDER_USER]=${v:-$CFG[GPODDER_USER]}
read -rsp "gPodder password [$CFG[GPODDER_PASS]]: "; echo; CFG[GPODDER_PASS]=${v:-$CFG[GPODDER_PASS]}
echo
echo "Summary:"
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
if [ ! -w "$INSTALL_DIR" ]; then
echo "ERROR: Cannot write to $INSTALL_DIR. Check permissions."
exit 1
fi
REQUIRED_DIRS=(
"music"
"podcasts"
"jingles"
"announcements"
"playlists"
"state"
"logs"
)
# -------------------------------------------------------
# Helper: prompt with default value
# -------------------------------------------------------
prompt() {
local var_name="$1"
local prompt_text="$2"
local default_value="${3:-}"
local hide_input="${4:-false}"
if [ -n "$default_value" ]; then
read -rp "$prompt_text [$default_value]: " input
if [ -z "$input" ]; then
eval "$var_name=$default_value"
else
eval "$var_name=\$input"
fi
else
if [ "$hide_input" = "true" ]; then
read -rsp "$prompt_text: " input
echo ""
eval "$var_name=\$input"
else
read -rp "$prompt_text: " input
eval "$var_name=\$input"
fi
fi
}
prompt_confirm() {
local var_name="$1"
local prompt_text="$2"
local default_value="${3:-no}"
read -rp "$prompt_text [$default_value]: " input
if [ -z "$input" ]; then
input="$default_value"
fi
case "$input" in
[Yy]* ) eval "$var_name=true" ;;
* ) eval "$var_name=false" ;;
esac
}
# -------------------------------------------------------
# 1. Interactive configuration
# -------------------------------------------------------
CONFIG_FILE="$INSTALL_DIR/config.json"
# Load existing config as defaults if present
EXISTING_CONFIG=false
if [ -f "$CONFIG_FILE" ]; then
EXISTING_CONFIG=true
echo "--- Loading existing config.json for defaults ---"
ICECAST_HOST_DEFAULT=$(jq -r '.icecast.host // "localhost"' "$CONFIG_FILE")
ICECAST_PORT_DEFAULT=$(jq -r '.icecast.port // 7777' "$CONFIG_FILE")
ICECAST_MOUNT_DEFAULT=$(jq -r '.icecast.mount // "/audio.mp3"' "$CONFIG_FILE")
ICECAST_USERNAME_DEFAULT=$(jq -r '.icecast.username // "source"' "$CONFIG_FILE")
GPODDER_ENABLE_DEFAULT=$(jq -r 'if .gpodder.enable == true then "yes" else "no" end' "$CONFIG_FILE")
GPODDER_HOST_DEFAULT=$(jq -r '.gpodder.host // "https://gpodder.net"' "$CONFIG_FILE")
GPODDER_USERNAME_DEFAULT=$(jq -r '.gpodder.username // ""' "$CONFIG_FILE")
else
ICECAST_HOST_DEFAULT="localhost"
ICECAST_PORT_DEFAULT="7777"
ICECAST_MOUNT_DEFAULT="/audio.mp3"
ICECAST_USERNAME_DEFAULT="source"
GPODDER_ENABLE_DEFAULT="no"
GPODDER_HOST_DEFAULT="https://gpodder.net"
GPODDER_USERNAME_DEFAULT=""
fi
echo ""
echo "--- Icecast Settings ---"
echo ""
ICECAST_HOST=""
ICECAST_PORT=""
ICECAST_MOUNT=""
ICECAST_USERNAME=""
ICECAST_PASSWORD=""
while true; do
prompt ICECAST_HOST "Icecast host" "$ICECAST_HOST_DEFAULT"
[[ "$ICECAST_HOST" =~ ^[a-zA-Z0-9._-]+$ ]] && break
echo "Invalid hostname. Try again."
# --- Create directory structure ---
for d in music podcasts jingles announcements playlists state logs; do
mkdir -p "$INSTALL_DIR/$d"
done
while true; do
prompt ICECAST_PORT "Source port" "$ICECAST_PORT_DEFAULT"
[[ "$ICECAST_PORT" =~ ^[0-9]{1,5}$ ]] && (( ICECAST_PORT >= 1 )) && (( ICECAST_PORT <= 65535 )) && break
echo "Port must be a number between 1 and 65535. Try again."
done
while true; do
prompt ICECAST_MOUNT "Mount point" "$ICECAST_MOUNT_DEFAULT"
[[ "$ICECAST_MOUNT" == /* ]] && break
echo "Mount point must start with /. Try again."
done
prompt ICECAST_USERNAME "Source username" "$ICECAST_USERNAME_DEFAULT"
while true; do
prompt ICECAST_PASSWORD "Source password" "" "true"
[ -n "$ICECAST_PASSWORD" ] && break
echo "Password cannot be empty. Try again."
done
echo ""
echo "--- gPodder.net Sync Settings ---"
echo ""
GPODDER_ENABLE=false
if [ "$GPODDER_ENABLE_DEFAULT" = "yes" ]; then
prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "yes"
else
prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "no"
fi
GPODDER_HOST=""
GPODDER_USERNAME=""
GPODDER_PASSWORD=""
if [ "$GPODDER_ENABLE" = "true" ]; then
prompt GPODDER_HOST "gPodder host" "$GPODDER_HOST_DEFAULT"
while true; do
prompt GPODDER_USERNAME "gPodder username/email" "$GPODDER_USERNAME_DEFAULT"
[ -n "$GPODDER_USERNAME" ] && break
echo "Username cannot be empty. Try again."
done
while true; do
prompt GPODDER_PASSWORD "gPodder password" "" "true"
[ -n "$GPODDER_PASSWORD" ] && break
echo "Password cannot be empty. Try again."
done
fi
echo ""
echo "--- Summary ---"
echo " Icecast: ${ICECAST_HOST}:${ICECAST_PORT}${ICECAST_MOUNT} (user: ${ICECAST_USERNAME})"
echo " gPodder: $( [ "$GPODDER_ENABLE" = "true" ] && echo "enabled (${GPODDER_USERNAME}@${GPODDER_HOST})" || echo "disabled" )"
echo ""
CONFIRM_INSTALL=false
prompt_confirm CONFIRM_INSTALL "Proceed with these settings?" "yes"
if [ "$CONFIRM_INSTALL" != "true" ]; then
echo "Aborted by user."
exit 0
fi
# -------------------------------------------------------
# 2. System packages
# -------------------------------------------------------
echo ""
echo "--- Installing system packages ---"
apt-get update
apt-get install -y \
liquidsoap \
icecast2 \
jq \
python3 \
python3-pip \
python3-venv \
ffmpeg \
lame \
libtag1-dev \
curl \
ca-certificates
# -------------------------------------------------------
# 3. Python virtual environment
# -------------------------------------------------------
echo "--- Setting up Python virtualenv ---"
VENV_DIR="$INSTALL_DIR/.venv"
python3 -m venv "$VENV_DIR"
"$VENV_DIR/bin/pip" install --upgrade pip
"$VENV_DIR/bin/pip" install \
feedparser \
requests \
mutagen
PYTHON_BIN="$VENV_DIR/bin/python3"
# -------------------------------------------------------
# 4. Create and verify directory structure
# -------------------------------------------------------
echo "--- Creating directory structure ---"
MISSING=()
for dir in "${REQUIRED_DIRS[@]}"; do
full_path="$INSTALL_DIR/$dir"
if [ ! -d "$full_path" ]; then
mkdir -p "$full_path"
MISSING+=("$dir")
fi
done
if [ ${#MISSING[@]} -gt 0 ]; then
echo " Created: ${MISSING[*]}"
else
echo " All directories already present."
fi
FAILED=()
for dir in "${REQUIRED_DIRS[@]}"; do
if [ ! -d "$INSTALL_DIR/$dir" ]; then
FAILED+=("$dir")
fi
done
if [ ${#FAILED[@]} -gt 0 ]; then
echo "ERROR: Failed to create directories: ${FAILED[*]}"
exit 1
fi
# -------------------------------------------------------
# 5. Generate config.json from collected settings
# -------------------------------------------------------
echo "--- Generating config.json ---"
cat > "$CONFIG_FILE" << EOF
# --- Generate config.json ---
cat > "$INSTALL_DIR/config.json" <<EOF
{
"icecast": {
"host": "${ICECAST_HOST}",
"port": ${ICECAST_PORT},
"mount": "${ICECAST_MOUNT}",
"username": "${ICECAST_USERNAME}",
"password": "${ICECAST_PASSWORD}"
"host": "${CFG[ICE_HOST]}",
"port": ${CFG[ICE_PORT]},
"mount": "${CFG[ICE_MOUNT]}",
"username": "${CFG[ICE_USER]}",
"password": "${CFG[ICE_PASS]}"
},
"gpodder": {
"enable": ${GPODDER_ENABLE},
"host": "${GPODDER_HOST}",
"username": "${GPODDER_USERNAME}",
"password": "${GPODDER_PASSWORD}"
"enable": ${CFG[GPODDER_ENABLE]},
"host": "${CFG[GPODDER_HOST]}",
"username": "${CFG[GPODDER_USER]}",
"password": "${CFG[GPODDER_PASS]}"
}
}
EOF
chmod 600 "$INSTALL_DIR/config.json"
chmod 600 "$CONFIG_FILE"
echo " Written: $CONFIG_FILE"
# -------------------------------------------------------
# 6. Create schedule.txt template (if not present)
# -------------------------------------------------------
SCHEDULE_FILE="$INSTALL_DIR/schedule.txt"
if [ ! -f "$SCHEDULE_FILE" ]; then
echo "--- Creating schedule.txt template ---"
cat > "$SCHEDULE_FILE" << 'EOF'
# Radio Schedule
# Format: min hour dom mon dow TYPE TARGET [RUNLENGTH_SECONDS]
# TYPE: show | stream
# RUNLENGTH required for stream entries, ignored for show entries
#
# Examples:
# 0 8 * * 2 show hardcore_history
# 0 6 * * 1 stream http://example.org:8000/live.mp3 3600
EOF
else
echo "--- schedule.txt already exists, skipping ---"
# --- Ensure liquidsoap user exists ---
if ! id liquidsoap &>/dev/null; then
useradd --system --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin liquidsoap
fi
chown -R liquidsoap:liquidsoap "$INSTALL_DIR"
# -------------------------------------------------------
# 7. Verify required project files exist
# -------------------------------------------------------
echo "--- Verifying project files ---"
PROJECT_FILES=("station.liq" "fetch_podcasts.py" "update_playlists.py")
FILE_ERRORS=()
for f in "${PROJECT_FILES[@]}"; do
if [ ! -f "$INSTALL_DIR/$f" ]; then
FILE_ERRORS+=("$f")
else
echo " Found: $f"
fi
done
if [ ${#FILE_ERRORS[@]} -gt 0 ]; then
echo "WARNING: Missing expected files: ${FILE_ERRORS[*]}"
echo " The service will not start until these are in place."
fi
# -------------------------------------------------------
# 8. Create systemd service named after the directory
# -------------------------------------------------------
echo "--- Creating systemd service: ${SERVICE_NAME}.service ---"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF
# --- Write systemd service ---
cat > /etc/systemd/system/"$SERVICE_NAME".service <<EOF
[Unit]
Description=Liquidsoap radio automation (${SERVICE_NAME})
Description=Liquidsoap radio station ($SERVICE_NAME)
After=network.target icecast2.service
[Service]
Type=simple
User=${LIQUIDSOAP_USER}
WorkingDirectory=${INSTALL_DIR}
ExecStart=/usr/bin/liquidsoap ${INSTALL_DIR}/station.liq
User=liquidsoap
WorkingDirectory=$INSTALL_DIR
ExecStart=/usr/bin/liquidsoap $INSTALL_DIR/station.liq
Restart=on-failure
RestartSec=5
StandardOutput=append:${INSTALL_DIR}/logs/liquidsoap.log
StandardError=append:${INSTALL_DIR}/logs/liquidsoap.log
[Install]
WantedBy=multi-user.target
EOF
# -------------------------------------------------------
# 9. Create cron jobs (hourly fetch + hourly playlist update)
# -------------------------------------------------------
echo "--- Setting up cron jobs ---"
# --- 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"
FETCH_CRON="0 * * * * cd $INSTALL_DIR && $PYTHON_BIN fetch_podcasts.py >> $INSTALL_DIR/logs/fetch.log 2>&1"
UPDATE_CRON="30 * * * * cd $INSTALL_DIR && $PYTHON_BIN update_playlists.py >> $INSTALL_DIR/logs/update.log 2>&1"
# Pull existing crontab (empty if none)
EXISTING_CRON=$(crontab -l -u "$LIQUIDSOAP_USER" 2>/dev/null || true)
# Remove any stale entries for these scripts to avoid duplicates on re-runs
CLEANED_CRON=$(echo "$EXISTING_CRON" | grep -v "fetch_podcasts.py\|update_playlists.py" || true)
# Add fetch job if not present
if ! echo "$EXISTING_CRON" | grep -q "fetch_podcasts.py"; then
CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON
}$FETCH_CRON"
echo " Added: fetch_podcasts.py (hourly)"
else
echo " Skipped: fetch_podcasts.py job already exists"
fi
# Add update job if not present
if ! echo "$EXISTING_CRON" | grep -q "update_playlists.py"; then
CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON
}$UPDATE_CRON"
echo " Added: update_playlists.py (hourly at :30)"
else
echo " Skipped: update_playlists.py job already exists"
fi
# Write back the combined crontab
echo "$CLEANED_CRON" | crontab -u "$LIQUIDSOAP_USER" -
# -------------------------------------------------------
# 10. Permissions
# -------------------------------------------------------
echo "--- Setting ownership and permissions ---"
id -u "$LIQUIDSOAP_USER" &>/dev/null || useradd --system --shell /usr/sbin/nologin "$LIQUIDSOAP_USER"
chown -R "${LIQUIDSOAP_USER}:${LIQUIDSOAP_USER}" "$INSTALL_DIR"
chmod 700 "$INSTALL_DIR/state"
chmod 700 "$INSTALL_DIR/logs"
(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 enable "$SERVICE_NAME"
# -------------------------------------------------------
# 11. Final verification
# -------------------------------------------------------
echo ""
echo "=== Final Verification ==="
ALL_OK=true
for dir in "${REQUIRED_DIRS[@]}"; do
if [ -d "$INSTALL_DIR/$dir" ] && [ -r "$INSTALL_DIR/$dir" ] && [ -w "$INSTALL_DIR/$dir" ]; then
printf " [OK] %-20s\n" "$dir/"
else
printf " [FAIL] %-20s (missing or inaccessible)\n" "$dir/"
ALL_OK=false
fi
done
if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then
echo " [OK] Service file: ${SERVICE_NAME}.service"
else
echo " [FAIL] Service file not created"
ALL_OK=false
fi
if [ -f "$CONFIG_FILE" ]; then
if jq empty "$CONFIG_FILE" 2>/dev/null; then
echo " [OK] Config: config.json (valid JSON)"
else
echo " [FAIL] Config: config.json (invalid JSON)"
ALL_OK=false
fi
else
echo " [FAIL] Config file missing"
ALL_OK=false
fi
if [ -f "$SCHEDULE_FILE" ]; then
echo " [OK] Schedule: schedule.txt"
else
echo " [FAIL] Schedule file missing"
ALL_OK=false
fi
echo ""
if [ "$ALL_OK" = true ]; then
echo "=== Installation Complete ==="
else
echo "=== Installation Finished With Errors ==="
echo "Review the [FAIL] items above before starting the service."
fi
echo ""
echo
echo "=== Installation complete ==="
echo "Next steps:"
echo " 1. Place your music in $INSTALL_DIR/music/"
echo " 2. Edit $SCHEDULE_FILE with your show/stream schedule"
echo " 3. Review/edit $INSTALL_DIR/station.liq"
echo " 4. Test: sudo systemctl start icecast2 && sudo systemctl start ${SERVICE_NAME}"
echo " 5. Check logs: tail -f $INSTALL_DIR/logs/liquidsoap.log"
echo ""
echo "Manage shows:"
echo " List: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --list-shows --detail"
echo " Delete: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --delete-show <slug>"
echo " Import OPML: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --import-opml <file.opml>"
echo " Add show: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --add-show <feed-url>"
echo " JSON state: $PYTHON_BIN $INSTALL_DIR/update_playlists.py --json"
echo " 1. Drop MP3/M4A files into $INSTALL_DIR/music/"
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,116 +1,108 @@
# station.liq - Main liquidsoap configuration
# All paths are resolved relative to this file's location.
# Move the whole directory tree and nothing else needs changing.
#!/usr/bin/env liquidsoap
# -------------------------------------------------------
# Resolve root directory from script location
# -------------------------------------------------------
let ROOT = configure.bindir()
# ---------------------------------------------------------------
# station.liq - Liquidsoap configuration for radio automation
# Auto-detects its own location so all paths are relative.
# ---------------------------------------------------------------
let MUSIC_DIR = ROOT ^ "/music"
let PODCASTS_DIR = ROOT ^ "/podcasts"
let PLAYLISTS_DIR = ROOT ^ "/playlists"
let SCHEDULE_FILE = ROOT ^ "/schedule.txt"
let CONFIG_FILE = ROOT ^ "/config.json"
configure.bindir()
# -------------------------------------------------------
# Load Icecast credentials from config.json
# -------------------------------------------------------
let cfg = json.from_file(CONFIG_FILE)
let ic_host = json.(cfg.icecast.host)
let ic_port = int_of_string(json.(cfg.icecast.port))
let ic_mount = json.(cfg.icecast.mount)
let ic_user = json.(cfg.icecast.username)
let ic_pass = json.(cfg.icecast.password)
set("log.file.path", "#{bindir()}/logs/liquidsoap.log")
set("log.stdout", true)
set("log.level", 3)
# -------------------------------------------------------
# Background music source
# -------------------------------------------------------
let music =
playlist.recursive(
mode="random",
duration=3600,
path=MUSIC_DIR,
extensions=["mp3", "m4a", "ogg", "flac"]
# --- Load Icecast credentials from config.json ---
let json.parse (cfg : {
icecast: {
host: string,
port: int,
mount: string,
username: string,
password: string
},
gpodder: {
enable: bool,
host: string,
username: string,
password: string
}
}) = file.contents("#{bindir()}/config.json")
let ic_host = cfg.icecast.host
let ic_port = cfg.icecast.port
let ic_mount = cfg.icecast.mount
let ic_username = cfg.icecast.username
let ic_password = cfg.icecast.password
# --- Background music library (recursive scan for mp3/m4a) ---
music_dir = "#{bindir()}/music"
music_playlist =
request.cue(
playlist(
recurse=true,
pattern="\\.(mp3|m4a)$",
"#{music_dir}"
)
)
# -------------------------------------------------------
# Scheduled content via request queue
# -------------------------------------------------------
def q = ref []
# --- Scheduled content: request queue fed by schedule.txt ---
sched_queue = request.queue(id="scheduler")
def push_request(req) =
q := req :: (!q)
# --- Jingles / announcements (optional) ---
jingle_dir = "#{bindir()}/jingles"
announce_dir = "#{bindir()}/announcements"
# Build a show source from its .pls file (written by update_playlists.py)
def show_source(slug) =
let pls_path = PLAYLISTS_DIR ^ "/" ^ slug ^ ".pls"
if file.test(pls_path) then
log("# Playing scheduled show: #{slug}")
Some(request.create(pls_path))
else
log("! No playlist found for show: #{slug}, skipping")
None
end
# Parse schedule.txt and register cron-triggered tasks
def load_schedule() =
def has_audio(dir) =
try
if not(file.test(SCHEDULE_FILE)) then
log("WARNING: schedule.txt not found at #{SCHEDULE_FILE}")
else
let lines = file.lines(SCHEDULE_FILE)
List.iter(fun line ->
let t = String.strip(line)
if t != "" && not(String.starts_with(t, "#")) then
let p = String.split(t, sep=" ")
|> List.filter(fun x -> String.strip(x) != "")
if List.length(p) >= 7 then
let cron_expr = String.concat(sep=" ", List.take(p, 5))
let stype = List.nth(p, 5)
let target = List.nth(p, 6)
match stype with
| "show" ->
cron.add(id=target, cron_expr, fun () ->
match show_source(target) with
| Some(r) -> push_request r
| None -> ()
)
| "stream" ->
let runlen_str =
if List.length(p) > 7 then List.nth(p, 7) else "3600"
let runlen = int_of_string(runlen_str)
cron.add(id=target, cron_expr, fun () ->
log("Triggering stream: #{target} (#{runlen}s)")
# For external streams, create a request with duration cap
let s = single.file(fallback=false, target)
push_request(request.create(target))
)
| _ ->
log("Unknown schedule type '#{stype}' for target '#{target}', ignoring")
end
else
log("Malformed schedule line (need at least 7 fields): #{t}")
let l = list.filter(fun(f) -> regexp("\\.(mp3|m4a)$").test(f), ls.dir(dir))
list.length(l) > 0
catch _ do
false
end
end
) lines
log("Schedule loaded from #{SCHEDULE_FILE}")
with e ->
log("ERROR loading schedule: #{e}")
# Load schedule once at startup
thread.run(at=now(), fun () -> load_schedule())
jingles_src =
if has_audio(jingle_dir) then
mksafe(request.cue(playlist(recurse=true, pattern="\\.(mp3|m4a)$", jingle_dir)))
else
null()
end
# -------------------------------------------------------
# Output: scheduled content over background music fallback
# -------------------------------------------------------
announcements_src =
if has_audio(announce_dir) then
mksafe(request.cue(playlist(recurse=true, pattern="\\.(mp3|m4a)$", announce_dir)))
else
null()
end
# --- Assemble the stream ---
primary = fallback(track_sensitive=false, [sched_queue, music_playlist])
content =
match jingles_src with
| null() => primary
| src => random(weights=[1, 8], [src, primary])
end
final_content =
match announcements_src with
| null() => content
| src => fallback(track_sensitive=false, [src, content])
end
radio = normalize(final_content)
# --- Output to Icecast ---
output.icecast(
%mp3(bitrate=128),
%mp3(bitrate=128, samplerate=44100, stereo=true),
host=ic_host,
port=ic_port,
user=ic_user,
password=ic_pass,
user=ic_username,
password=ic_password,
mount=ic_mount,
fallback=music,
request.queue(q)
name="Radio Station",
description="Automated internet radio",
genre="Various",
public=true,
radio
)

View file

@ -1,26 +1,162 @@
#!/usr/bin/env python3
"""Regenerate per-show playlist files based on playback history."""
"""
update_playlists.py - Regenerate per-show .pls playlists based on playback history.
Runs via cron hourly at :30.
"""
import os
import sys
import argparse
import json
import logging
import re
import sqlite3
import sys
from pathlib import Path
from datetime import datetime
AUDIO_ROOT = Path(__file__).resolve().parent
PLAYLISTS_DIR = AUDIO_ROOT / "playlists"
STATE_DB = AUDIO_ROOT / "state" / "played.db"
SUBS_DB = AUDIO_ROOT / "state" / "subscriptions.db"
SHOWS_DIR = AUDIO_ROOT / "podcasts"
ROOT = Path(__file__).resolve().parent
STATE_DIR = ROOT / "state"
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"}
def get_played_db():
STATE_DB.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(STATE_DB))
conn.execute("""CREATE TABLE IF NOT EXISTS played (
filename TEXT PRIMARY KEY,
show TEXT,
played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOGS_DIR / "update.log"),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger("update_playlists")
def open_subs_db():
conn = sqlite3.connect(SUBS_DB)
conn.row_factory = sqlite3.Row
return conn
def open_played_db():
conn = sqlite3.connect(PLAYED_DB)
conn.row_factory = sqlite3.Row
conn.execute("""
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,
duration_seconds INTEGER,
played_at TEXT,
UNIQUE(show_slug, guid)
)
""")
conn.commit()
return conn
def find_audio_files(directory):
"""Recursively scan for .mp3/.m4a files."""
results = []
if not directory.exists():
return results
for p in sorted(directory.rglob("*")):
if p.is_file() and p.suffix.lower() in AUDIO_EXTS:
results.append(str(p))
return results
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)
if not files:
return None
played_rows = played_db.execute(
"SELECT file_path, played_at FROM episodes WHERE show_slug = ? AND played_at IS NOT NULL",
(slug,),
).fetchall()
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]
if unplayed:
return unplayed[0]
# All played: pick least recently played
if played_paths:
return min(played_paths.items(), key=lambda kv: (kv[1] or ""))[0]
return files[0]
def write_pls(filepath, out_path):
"""Write a .pls playlist pointing at a single file."""
abs_path = str(Path(filepath).resolve())
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.write_text(content)
def mark_as_played(slug, filepath, duration, played_db):
played_db.execute(
"UPDATE episodes SET played_at = datetime('now') WHERE show_slug = ? AND file_path = ?",
(slug, filepath),
)
played_db.commit()
def update_all():
subs_db = open_subs_db()
played_db = open_played_db()
shows = subs_db.execute("SELECT slug, name FROM shows ORDER BY name").fetchall()
for show in shows:
slug = show["slug"]
selected = select_unplayed_episode(slug, played_db)
if selected is None:
log.info("%s: no audio files found, skipping.", slug)
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"
write_pls(selected, out_pls)
mark_as_played(slug, selected, duration, played_db)
log.info("%s: queued %s", slug, Path(selected).name)
subs_db.close()
played_db.close()
def json_summary():
subs_db = open_subs_db()
played_db = open_played_db()
shows = subs_db.execute("SELECT slug FROM shows ORDER BY name").fetchall()
summary = {}
for show in shows:
slug = show["slug"]
files = find_audio_files(PODCASTS_DIR / slug)
played_count = played_db.execute(
"SELECT COUNT(*) as c FROM episodes WHERE show_slug = ? AND played_at IS NOT NULL",
(slug,),
).fetchone()["c"]
summary[slug] = {"total_files": len(files), "played_count": played_count}
subs_db.close()
played_db.close()
print(json.dumps(summary, indent=2))
def main():
parser = argparse.ArgumentParser(description="Playlist updater for radio automation")
parser.add_argument("--json", action="store_true", help="Emit JSON summary and exit")
args = parser.parse_args()
STATE_DIR.mkdir(exist_ok=True)
LOGS_DIR.mkdir(exist_ok=True)
PLAYLISTS_DIR.mkdir(exist_ok=True)
if args.json:
json_summary()
else:
update_all()
if __name__ == "__main__":
main()

View file

@ -1,138 +1,154 @@
#!/usr/bin/env jruby
# frozen_string_literal: true
#
# update_playlists.rb - Regenerate per-show playlist files based on playback history.
# JRuby-compatible (Ruby 3.1+ baseline).
require "json"
require "sqlite3"
require "fileutils"
require "time"
require "optparse"
AUDIO_ROOT = Pathname.new(File.expand_path(__dir__))
PLAYLISTS_DIR = AUDIO_ROOT.join("playlists")
STATE_DB = AUDIO_ROOT.join("state/played.db")
SUBS_DB = AUDIO_ROOT.join("state/subscriptions.db")
SHOWS_DIR = AUDIO_ROOT.join("podcasts")
module RadioAutomation
ROOT = File.expand_path("..", __dir__)
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")
PLAYLISTS_DIR = File.join(ROOT, "playlists")
LOGS_DIR = File.join(ROOT, "logs")
AUDIO_EXTS = [".mp3", ".m4a"]
module Radio
class PlaylistUpdater
def initialize
FileUtils.mkdir_p(PLAYLISTS_DIR)
FileUtils.mkdir_p(STATE_DB.dirname)
@played_db = connect_played_db
@subs_db = connect_subs_db
def self.log_info(msg)
puts "#{Time.now.iso8601} [INFO] #{msg}"
append_log("update.log", msg)
end
attr_reader :played_db, :subs_db
def self.append_log(filename, msg)
FileUtils.mkdir_p(LOGS_DIR)
File.open(File.join(LOGS_DIR, filename), "a") { |f| f.puts msg }
rescue StandardError
nil
end
def connect_played_db
conn = SQLite3::Database.new(STATE_DB.to_s)
conn.execute(<<~SQL)
CREATE TABLE IF NOT EXISTS played (
filename TEXT PRIMARY KEY,
show TEXT,
played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
def self.open_subs_db
db = SQLite3::Database.new(SUBS_DB)
db.results_as_hash = true
db
end
def self.open_played_db
db = SQLite3::Database.new(PLAYED_DB)
db.results_as_hash = true
db.execute <<-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,
duration_seconds INTEGER,
played_at TEXT,
UNIQUE(show_slug, guid)
)
SQL
conn
db
end
def connect_subs_db
conn = SQLite3::Database.new(SUBS_DB.to_s)
conn
def self.find_audio_files(directory)
return [] unless Dir.exist?(directory)
Dir.glob(File.join(directory, "**", "*")).select do |f|
File.file?(f) && AUDIO_EXTS.any? { |ext| f.end_with?(ext) }
end.sort
end
# Build a .pls for each show: pick an unplayed episode, mark it played.
def regenerate_all
shows = subs_db.execute("SELECT name, slug FROM shows ORDER BY name")
if shows.empty?
puts "No shows registered."
return
end
def self.select_unplayed_episode(slug, played_db)
files = find_audio_files(File.join(PODCASTS_DIR, slug))
return nil if files.empty?
regenerated = 0
shows.each do |_name, slug|
if regenerate_show_pls(slug)
regenerated += 1
end
end
puts "Regenerated #{regenerated} playlist(s)."
end
def regenerate_show_pls(slug)
show_dir = SHOWS_DIR.join(slug)
return false unless Dir.exist?(show_dir)
# Gather candidate audio files recursively
candidates = Dir.glob(show_dir.join("**/*.{mp3,m4a,ogg,flac}")).sort
return false if candidates.empty?
# Determine which have already been played
played_rows = played_db.execute("SELECT filename FROM played WHERE show=?", slug)
played_set = played_rows.map { |r| File.basename(r[0]) }.to_set
unplayed = candidates.reject { |path| played_set.include?(File.basename(path)) }
# Fall back to the least-recently-played (or any) if nothing is unplayed
chosen = unplayed.first || lru_episode(candidates, slug)
return false if chosen.nil?
pls_path = PLAYLISTS_DIR.join("#{slug}.pls")
File.write(pls_path, "#{chosen}\n")
# Record playback so the next cycle picks a different episode
played_db.execute(
"INSERT OR REPLACE INTO played (filename, show) VALUES (?, ?)",
[chosen.to_s, slug]
played_rows = played_db.query_all(
"SELECT file_path, played_at FROM episodes WHERE show_slug = ? AND played_at IS NOT NULL", slug
)
played_db.commit
played_map = played_rows.each_with_object({}) { |r, h| h[r["file_path"]] = r["played_at"] }
puts " #{slug}: queued #{File.basename(chosen)}"
true
end
unplayed = files.reject { |f| played_map.key?(f) }
return unplayed.first if unplayed.any?
def lru_episode(candidates, slug)
played_rows = played_db.execute(
"SELECT filename, played_at FROM played WHERE show=? ORDER BY played_at ASC", slug
)
played_map = played_rows.to_h { |fname, ts| [File.basename(fname), ts] }
candidates.min_by { |c| played_map[File.basename(c)] || Time.at(0) }
end
def dump_json
shows = subs_db.execute("SELECT name, slug FROM shows ORDER BY name")
out = {}
shows.each do |_name, slug|
show_dir = SHOWS_DIR.join(slug)
next unless Dir.exist?(show_dir)
files = Dir.glob(show_dir.join("**/*.{mp3,m4a,ogg,flac}")).size
played = played_db.get_first_row("SELECT COUNT(*) FROM played WHERE show=?", slug)[0]
out[slug] = { total_files: files, played: played }
end
puts JSON.pretty_generate(out)
end
def close
played_db&.close
subs_db&.close
end
end
end
require "set"
def main
u = Radio::PlaylistUpdater.new
if ARGV.include?("--json")
u.dump_json
if played_map.any?
played_map.min_by { |_path, ts| ts.to_s }[0]
else
u.regenerate_all
files.first
end
ensure
u&.close
end
main
def self.write_pls(filepath, out_path)
abs = File.absolute_path(filepath)
content = "[playlist]\nFile1=#{abs}\nTitle1=Radio Episode\nLength1=-1\nNumberOfEntries=1\nVersion=2\n"
FileUtils.mkdir_p(File.dirname(out_path))
File.write(out_path, content)
end
def self.mark_as_played(slug, filepath, played_db)
played_db.execute(
"UPDATE episodes SET played_at = datetime('now') WHERE show_slug = ? AND file_path = ?",
[slug, filepath]
)
end
def self.update_all
subs_db = open_subs_db
played_db = open_played_db
shows = subs_db.query_all("SELECT slug, name FROM shows ORDER BY name")
shows.each do |show|
slug = show["slug"]
selected = select_unplayed_episode(slug, played_db)
if selected.nil?
log_info("#{slug}: no audio files found, skipping.")
next
end
out_pls = File.join(PLAYLISTS_DIR, "#{slug}.pls")
write_pls(selected, out_pls)
mark_as_played(slug, selected, played_db)
log_info("#{slug}: queued #{File.basename(selected)}")
end
subs_db.close
played_db.close
end
def self.json_summary
subs_db = open_subs_db
played_db = open_played_db
shows = subs_db.query_all("SELECT slug FROM shows ORDER BY name")
summary = {}
shows.each do |show|
slug = show["slug"]
files = find_audio_files(File.join(PODCASTS_DIR, slug))
played_count = played_db.get_first_value(
"SELECT COUNT(*) FROM episodes WHERE show_slug = ? AND played_at IS NOT NULL", slug
)
summary[slug] = { "total_files" => files.size, "played_count" => played_count }
end
subs_db.close
played_db.close
puts JSON.pretty_generate(summary)
end
def self.main
options = {}
OptionParser.new do |opts|
opts.banner = "Usage: update_playlists.rb [options]"
opts.on("--json", "Emit JSON summary and exit") { options[:json] = true }
end.parse!
FileUtils.mkdir_p(STATE_DIR)
FileUtils.mkdir_p(LOGS_DIR)
FileUtils.mkdir_p(PLAYLISTS_DIR)
if options[:json]
json_summary
else
update_all
end
end
end
RadioAutomation.main