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 # Radio Automation Stack
A self-hosted internet radio automation system for Linux Mint 22.3 (Ubuntu 24.04 Noble). It continuously plays background music, interrupts it with scheduled podcast shows and external streams, downloads new podcast episodes from RSS feeds, and manages playback history — all fed to an Icecast server via liquidsoap. A self-hosted internet radio automation system for Linux Mint 22.3 (Ubuntu 24.04 Noble). It continuously plays background music, interrupts it with scheduled podcast shows and external streams, downloads new podcast episodes from RSS feeds, and manages playback history — all fed to an Icecast server via liquidsoap.
@ -48,7 +50,6 @@ The moving parts:
| `config.json` | Credentials and connection settings | No (secret) | | `config.json` | Credentials and connection settings | No (secret) |
| `schedule.txt` | Human-edited cron schedule of shows/streams | Yes | | `schedule.txt` | Human-edited cron schedule of shows/streams | Yes |
| `station.liq` | liquidsoap configuration | Yes | | `station.liq` | liquidsoap configuration | Yes |
| `Gemfile` | Ruby gem dependencies (Ruby stack) | Yes |
## Configuration: `config.json` ## 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: 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. - 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/`. - 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. - 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 #!/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 argparse
import sys
import json import json
import logging
import re
import shutil import shutil
import sqlite3 import sqlite3
import hashlib import sys
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
from datetime import datetime from urllib.parse import quote
from urllib.parse import urlparse
import feedparser import feedparser
import requests import requests
AUDIO_ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
DB_PATH = AUDIO_ROOT / "state" / "subscriptions.db" CONFIG_PATH = ROOT / "config.json"
DOWNLOAD_DIR = AUDIO_ROOT / "podcasts" STATE_DIR = ROOT / "state"
CONFIG_PATH = AUDIO_ROOT / "config.json" 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(): def load_config():
with open(CONFIG_PATH, "r") as f: with open(CONFIG_PATH) as f:
return json.load(f) return json.load(f)
def open_subs_db():
def get_db(): conn = sqlite3.connect(SUBS_DB)
DB_PATH.parent.mkdir(parents=True, exist_ok=True) conn.row_factory = sqlite3.Row
conn = sqlite3.connect(str(DB_PATH)) conn.execute("""
conn.execute("""CREATE TABLE IF NOT EXISTS shows ( CREATE TABLE IF NOT EXISTS shows (
id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL, name TEXT NOT NULL,
feed_url TEXT UNIQUE NOT NULL, feed_url TEXT NOT NULL UNIQUE,
slug TEXT UNIQUE NOT NULL, source TEXT DEFAULT 'manual',
opml_import INTEGER DEFAULT 0 opml_import INTEGER DEFAULT 0,
)""") created_at TEXT DEFAULT (datetime('now'))
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")
conn.commit() conn.commit()
return conn 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): def slugify(name):
"""Convert a show name to a filesystem-safe slug.""" s = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
slug = name.lower().strip() return s[:60] or "show"
slug = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in slug)
slug = "_".join(slug.split("_"))
return slug[:80]
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): print(f"--- Syncing subscriptions from {base} ---")
"""Register shows from OPML XML data. print(f"Fetching subscriptions for '{username}'...")
Uses upsert semantics: if a show with the same feed_url already exists, resp = requests.get(
no duplicate row is created. Its opml_import flag is upgraded to 1 if url,
this import marks it as such (protecting it from gpodder pruning). auth=(username, password),
""" headers={"User-Agent": "radio-automation/1.0"},
root = ET.fromstring(xml_bytes) timeout=60,
added = 0 )
updated_flag = 0
skipped = 0
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"): for outline in root.iter("outline"):
xml_url = outline.get("xmlUrl", "") feed_url = (outline.attrib.get("xmlUrl") or "").strip()
if not xml_url: name = (outline.attrib.get("text") or "").strip()
continue if not feed_url or not re.match(r"^https?://", feed_url):
otype = outline.get("type", "")
if otype and otype != "rss":
continue continue
shows.append({"name": name, "feed_url": feed_url})
return shows
show_name = outline.get("text") or outline.get("title") or "Unknown Show" def register_remote_shows(remote_shows):
slug = sanitize_slug(show_name) db = open_subs_db()
flag = 1 if opml_import else 0 added = 0
for show in remote_shows:
existing = db.execute( slug = slugify(show["name"])
"SELECT id, opml_import FROM shows WHERE feed_url=?", (xml_url,) existing = db.execute("SELECT slug FROM shows WHERE slug = ?", (slug,)).fetchone()
).fetchone() if existing is None:
if existing:
existing_id, existing_flag = existing
if flag == 1 and existing_flag == 0:
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( db.execute(
"INSERT INTO shows (name, feed_url, slug, opml_import) VALUES (?, ?, ?, ?)", "INSERT INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'gpodder', 0)",
(show_name, xml_url, slug, flag) (slug, show["name"], show["feed_url"]),
) )
log.info("Registered new show: %s (%s)", show["name"], slug)
added += 1 added += 1
print(f" Registered: {show_name} -> {slug}")
db.commit() db.commit()
db.close()
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).")
return added return added
def prune_stale_shows(remote_shows):
def import_opml_file(filepath): db = open_subs_db()
"""Import shows from a local OPML file.""" remote_slugs = {slugify(s["name"]) for s in remote_shows}
try: stale = db.execute(
with open(filepath, "rb") as f: "SELECT slug, name FROM shows WHERE source = 'gpodder' AND opml_import = 0"
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"
).fetchall() ).fetchall()
removed = 0
to_remove = [] for row in stale:
for slug, feed_url, name in rows: if row["slug"] not in remote_slugs:
if feed_url not in gp_feed_urls: remove_show_data(row["slug"])
to_remove.append((slug, name)) db.execute("DELETE FROM shows WHERE slug = ?", (row["slug"],))
log.info("Pruned stale show: %s (%s)", row["name"], row["slug"])
if not to_remove: removed += 1
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))
db.commit() db.commit()
db.close() db.close()
return removed
show_dir = DOWNLOAD_DIR / slug def extract_duration(entry):
if show_dir.exists(): dur = entry.get("media_duration") or entry.get("duration")
shutil.rmtree(show_dir) if dur:
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}")
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: try:
raw_len = str(enclosure.get("length", "0")) return int(float(dur))
if ":" in raw_len: except (ValueError, TypeError):
parts = raw_len.split(":")
duration_sec = int(parts[-1])
else:
duration_sec = int(raw_len)
except (ValueError, IndexError):
pass pass
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:
return int(int(length) * 8 / 128000)
except ValueError:
pass
return None
# Sanitize filename def fetch_feed(feed_url):
safe_title = "".join(c if c.isalnum() or c in ("-", "_", " ") else "_" for c in title) try:
safe_title = safe_title.strip()[:120] parsed = feedparser.parse(feed_url)
ext = ".mp3" except Exception as e:
if "ogg" in enclosure.get("type", "").lower(): log_error(f"Feed parse error for {feed_url}: {e}")
ext = ".ogg" return None
elif "m4a" in enclosure.get("type", "").lower() or "aac" in enclosure.get("type", "").lower(): if parsed.bozo and not parsed.entries:
ext = ".m4a" log_error(f"Bozo feed (no entries) for {feed_url}: {parsed.bozo_exception}")
return None
return parsed
filepath = show_dir / f"{safe_title}{ext}" def download_episode(url, dest_dir, filename):
dest = dest_dir / filename
if filepath.exists(): if dest.exists():
db.execute( return str(dest)
"INSERT OR IGNORE INTO seen (url, title, show_slug, duration_sec) VALUES (?, ?, ?, ?)", try:
(ep_url, title, slug, duration_sec) with requests.get(url, stream=True, timeout=120) as r:
) r.raise_for_status()
db.commit() tmp = dest.with_suffix(dest.suffix + ".part")
continue with open(tmp, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
# Download
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) f.write(chunk)
tmp.rename(dest)
return str(dest)
except requests.RequestException as e:
log_error(f"Download failed for {url}: {e}")
return None
db.execute( def safe_filename(title, fallback):
"INSERT OR IGNORE INTO seen (url, title, show_slug, duration_sec) VALUES (?, ?, ?, ?)", name = re.sub(r"[^\w\s.-]", "", title or "").strip().replace(" ", "_")
(ep_url, title, slug, duration_sec) return (name[:120] or fallback) + ".mp3"
)
db.commit() def fetch_show_episodes(slug, name, feed_url):
new_count += 1 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: except Exception as e:
print(f"[{show_name}] FAILED to download '{title}': {e}") log_error(f"Unexpected error fetching {show['slug']}: {e}")
log.info("=== Fetch complete: %d new episode(s) ===", total_new)
if new_count:
print(f"[{show_name}] Downloaded {new_count} new episode(s).")
else:
print(f"[{show_name}] No new episodes.")
def list_shows(detail=False): def list_shows(detail=False):
"""List all registered shows.""" db = open_subs_db()
db = get_db() rows = db.execute(
rows = db.execute("SELECT name, slug, feed_url, opml_import FROM shows ORDER BY name").fetchall() "SELECT slug, name, feed_url, source, opml_import FROM shows ORDER BY name"
).fetchall()
db.close()
if not rows: if not rows:
print("No shows registered.") print("No shows registered.")
db.close()
return return
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:
line += f"\n{'':<50} {r['feed_url']}"
print(line)
print(f"{'Show Name':<40} {'Slug':<30} {'Protected':<10}") def add_show(feed_url):
print("-" * 80) parsed = fetch_feed(feed_url)
for name, slug, feed_url, prot in rows: if parsed is None or not parsed.feed.get("title"):
prot_str = "yes" if prot else "no" log_error(f"Could not determine show title from {feed_url}")
print(f"{name:<40} {slug:<30} {prot_str:<10}") return
name = parsed.feed["title"]
if detail: slug = slugify(name)
print("\nFeed URLs:") db = open_subs_db()
for name, slug, feed_url, prot in rows: db.execute(
print(f" {slug}: {feed_url}") "INSERT OR IGNORE INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'manual', 0)",
(slug, name, feed_url),
)
db.commit()
db.close() 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(): def main():
if "--delete-show" in sys.argv: parser = argparse.ArgumentParser(description="Podcast fetcher for radio automation")
idx = sys.argv.index("--delete-show") parser.add_argument("--list-shows", action="store_true", help="List registered shows")
if idx + 1 < len(sys.argv): parser.add_argument("--detail", action="store_true", help="With --list-shows, show feed URLs")
delete_show(sys.argv[idx + 1]) parser.add_argument("--add-show", metavar="FEED_URL", help="Add a show from a feed URL")
else: parser.add_argument("--delete-show", metavar="SLUG", help="Delete a show and its data")
print("Usage: fetch_podcasts.py --delete-show <slug>") parser.add_argument("--import-opml", metavar="FILE", help="Import shows from an OPML file")
sys.exit(1) args = parser.parse_args()
return
if "--add-show" in sys.argv: STATE_DIR.mkdir(exist_ok=True)
idx = sys.argv.index("--add-show") LOGS_DIR.mkdir(exist_ok=True)
if idx + 1 < len(sys.argv): PODCASTS_DIR.mkdir(exist_ok=True)
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: config = load_config()
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()
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:
run_fetch(config)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View file

@ -1,482 +1,446 @@
#!/usr/bin/env jruby #!/usr/bin/env jruby
# frozen_string_literal: true # 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 "net/http"
require "uri" require "uri"
require "cgi"
require "json" require "json"
require "sqlite3" require "sqlite3"
require "rexml/document"
require "digest/md5"
require "fileutils" require "fileutils"
require "time" require "optparse"
AUDIO_ROOT = Pathname.new(File.expand_path(__dir__)) module RadioAutomation
DB_PATH = AUDIO_ROOT.join("state/subscriptions.db") ROOT = File.expand_path("..", __dir__)
DOWNLOAD_DIR = AUDIO_ROOT.join("podcasts") CONFIG_PATH = File.join(ROOT, "config.json")
CONFIG_PATH = AUDIO_ROOT.join("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 def self.log_info(msg)
class Fetcher puts "#{Time.now.iso8601} [INFO] #{msg}"
def initialize append_log("fetch.log", msg)
@db = get_db end
end
attr_reader :db def self.log_error(msg)
puts "#{Time.now.iso8601} [ERROR] #{msg}"
append_log("fetch.log", msg)
end
def load_config def self.append_log(filename, msg)
JSON.parse(File.read(CONFIG_PATH)) FileUtils.mkdir_p(LOGS_DIR)
end File.open(File.join(LOGS_DIR, filename), "a") { |f| f.puts msg }
rescue StandardError
nil
end
def get_db def self.load_config
FileUtils.mkdir_p(DB_PATH.dirname) JSON.parse(File.read(CONFIG_PATH))
conn = SQLite3::Database.new(DB_PATH.to_s) end
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) def self.slugify(name)
slug = name.downcase.strip s = name.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
slug = slug.gsub(/[^a-z0-9\-_]/, "_") s[0, 60] || "show"
slug = slug.split("_").reject(&:empty?).join("_") end
slug[0, 80]
end
# ---- Registration from OPML -------------------------------------------- # ---------------------------------------------------------
def register_shows_from_opml_xml(xml_bytes, opml_import: false) # Database
doc = Nokogiri::XML(xml_bytes) # ---------------------------------------------------------
added = updated_flag = skipped = 0 def self.open_subs_db
db = SQLite3::Database.new(SUBS_DB)
doc.xpath("//outline").each do |node| db.results_as_hash = true
xml_url = node["xmlUrl"].to_s db.execute <<-SQL
next if xml_url.empty? CREATE TABLE IF NOT EXISTS shows (
otype = node["type"].to_s slug TEXT PRIMARY KEY,
next unless otype.empty? || otype == "rss" name TEXT NOT NULL,
feed_url TEXT NOT NULL UNIQUE,
show_name = node["text"] || node["title"] || "Unknown Show" source TEXT DEFAULT 'manual',
slug = sanitize_slug(show_name) opml_import INTEGER DEFAULT 0,
flag = opml_import ? 1 : 0 created_at TEXT DEFAULT (datetime('now'))
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 SQL
puts " Registered: #{show_name} -> #{slug}" db
end
puts "\n--- Fetching episodes ---" def self.open_played_db
fetch_feed(show_name, feed_url, slug) 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
db
end
puts "\nDone. Episodes saved to: #{DOWNLOAD_DIR.join(slug)}/" # ---------------------------------------------------------
# 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
end
# ---- Core fetching --------------------------------------------------------- def self.parse_opml(xml_string)
def parse_feed(feed_url) doc = REXML::Document.new(xml_string)
body = http_get(feed_url) shows = []
return nil if body.nil? 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
doc = Nokogiri::XML(body) def self.register_remote_shows(remote_shows)
doc.remove_namespaces! 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
# Support both RSS (<channel>) and Atom (<feed>) def self.prune_stale_shows(remote_shows)
channel = doc.at_xpath("//channel") || doc.at_xpath("//feed") db = open_subs_db
return nil if channel.nil? 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
title = (channel.at_xpath("./title")&.text.presence || "Unknown Show") # ---------------------------------------------------------
# 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
entries = [] def self.fetch_feed(feed_url)
if doc.root.name == "rss" uri = URI.parse(feed_url)
doc.xpath("//item").each do |item| http = Net::HTTP.new(uri.host, uri.port)
enc = item.at_xpath(".//enclosure") http.use_ssl = (uri.scheme == "https")
next if enc.nil? http.open_timeout = 30
entries << { http.read_timeout = 60
url: enc["url"].to_s, req = Net::HTTP::Get.new(uri)
type: enc["type"].to_s, req["User-Agent"] = "radio-automation/1.0"
length: enc["length"].to_s, resp = http.request(req)
title: (item.at_xpath("./title")&.text.presence || "untitled") raise "Feed HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
} resp.body
end rescue StandardError => e
else log_error("Feed fetch error for #{feed_url}: #{e.message}")
doc.xpath("//entry").each do |entry| nil
link = entry.at_xpath("./link[@rel='enclosure']") || end
entry.at_xpath("./link")
next if link.nil? def self.download_episode(url, dest_dir, filename)
dur = entry.at_xpath(".//media:duration") FileUtils.mkdir_p(dest_dir)
entries << { dest = File.join(dest_dir, filename)
url: link["href"].to_s, return dest if File.exist?(dest)
type: link["type"].to_s,
length: dur ? dur.text : "", uri = URI.parse(url)
title: (entry.at_xpath("./title")&.text.presence || "untitled") 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
end end
File.rename(tmp, dest)
[title, entries] dest
rescue StandardError => e rescue StandardError => e
warn "Feed parse error for #{feed_url}: #{e.message}" log_error("Download failed for #{url}: #{e.message}")
File.delete(tmp) if File.exist?(tmp)
nil nil
end 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
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
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]
)
db.commit
next
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]
)
db.commit
new_count += 1
rescue StandardError => e
puts "[#{show_name}] FAILED to download '#{title}': #{e.message}"
end
end
if new_count.positive?
puts "[#{show_name}] Downloaded #{new_count} new episode(s)."
else
puts "[#{show_name}] No new episodes."
end
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
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"
end
def list_shows(detail: false)
rows = db.execute("SELECT name, slug, feed_url, opml_import FROM shows ORDER BY name")
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
end
end
def close
db&.close
end
end end
end
require "set" def self.safe_filename(title, fallback)
name = title.to_s.gsub(/[^\w\s.\-]/, "").strip.tr(" ", "_")[0, 120]
"#{name || fallback}.mp3"
end
def main def self.fetch_show_episodes(slug, name, feed_url)
args = ARGV.dup dest_dir = File.join(PODCASTS_DIR, slug)
f = Radio::Fetcher.new raw = fetch_feed(feed_url)
return 0 if raw.nil?
if args.include?("--delete-show") played_db = open_played_db
idx = args.index("--delete-show") seen = played_db.query("SELECT guid FROM episodes WHERE show_slug = ?", slug).map { |r| r["guid"] }
val = args[idx + 1] subs_db = open_subs_db
if val.nil? new_count = 0
puts "Usage: fetch_podcasts.rb --delete-show <slug>"
exit 1 # 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 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
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"))
else
cfg = f.load_config
gp = cfg.fetch("gpodder", {})
if gp.fetch("enable", false) played_db.close
puts "--- Syncing subscriptions from gpodder.net ---" subs_db.close
new_count
end
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 begin
f.sync_gpoddernet total_new += fetch_show_episodes(show["slug"], show["name"], show["feed_url"])
rescue SystemExit
raise
rescue StandardError => e rescue StandardError => e
puts "! gpodder sync failed (continuing with existing shows): #{e.message}" log_error("Unexpected error fetching #{show['slug']}: #{e.message}")
end end
end end
log_info("=== Fetch complete: #{total_new} new episode(s) ===")
end
shows = f.db.execute("SELECT name, feed_url, slug FROM shows") # ---------------------------------------------------------
if shows.empty? # Admin operations
puts "No shows registered. Import an OPML file, add a show, or enable gpodder sync." # ---------------------------------------------------------
else def self.list_shows(detail: false)
puts "--- Fetching episodes for #{shows.size} show(s) ---" db = open_subs_db
shows.each do |show_name, feed_url, slug| rows = db.query_all("SELECT slug, name, feed_url, source, opml_import FROM shows ORDER BY name")
begin db.close
f.fetch_feed(show_name, feed_url, slug) if rows.empty?
rescue StandardError => e puts "No shows registered."
puts "[#{show_name}] FAILED: #{e.message}" return
end end
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 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
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 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
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
db.close
log_info("OPML import: #{shows.size} show(s) processed.")
end
# ---------------------------------------------------------
# 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
added = register_remote_shows(remote)
pruned = prune_stale_shows(remote)
log_info("Sync: #{added} added, #{pruned} pruned.")
end
end
fetch_all_episodes
end
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
run_fetch(config)
end end
end end
ensure
f&.close
end end
main RadioAutomation.main

View file

@ -1,580 +1,200 @@
#!/bin/bash #!/usr/bin/env bash
# # install_for_jruby - Provision the JRuby-based radio automation stack.
# install_for_jruby - Setup for liquidsoap radio automation stack (JRuby edition) # Detects/reuses existing Java and JRuby; installs gems one at a time.
# Target: Linux Mint 22.3 (Ubuntu 24.04 Noble based) # Must be run as root from within the target directory (e.g. /srv/radio/).
# # Idempotent: safe to re-run.
# 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.
#
set -euo pipefail set -euo pipefail
# ------------------------------------------------------- INSTALL_DIR="$(pwd)"
# Determine install directory from script location
# -------------------------------------------------------
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
INSTALL_DIR="$(dirname "$SCRIPT_PATH")"
SERVICE_NAME="$(basename "$INSTALL_DIR")" 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" 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 "=== Radio Automation Installer (JRuby) ==="
echo "Install directory: $INSTALL_DIR" echo "Install dir: $INSTALL_DIR"
echo "Service name: $SERVICE_NAME" echo "Service name: $SERVICE_NAME"
echo "" echo
# ------------------------------------------------------- # --- Prompt for config values (pre-fill from existing config.json) ---
# 0. Validate environment EXISTING_CONFIG="$INSTALL_DIR/config.json"
# ------------------------------------------------------- declare -A CFG
if [ "$(id -u)" -ne 0 ]; then CFG[ICE_HOST]="localhost"
echo "ERROR: This script must be run as root (sudo)." CFG[ICE_PORT]="7777"
exit 1 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 fi
if [ ! -d "$INSTALL_DIR" ]; then read -rp "Icecast host [$CFG[ICE_HOST]]: " v; CFG[ICE_HOST]=${v:-$CFG[ICE_HOST]}
echo "ERROR: Install directory $INSTALL_DIR does not exist." read -rp "Icecast port [$CFG[ICE_PORT]]: " v; CFG[ICE_PORT]=${v:-$CFG[ICE_PORT]}
exit 1 read -rp "Icecast mount [$CFG[ICE_MOUNT]]: " v; CFG[ICE_MOUNT]=${v:-$CFG[ICE_MOUNT]}
fi 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
echo "ERROR: Cannot write to $INSTALL_DIR. Check permissions." echo "Summary:"
exit 1 echo " Icecast: ${CFG[ICE_HOST]}:${CFG[ICE_PORT]}${CFG[ICE_MOUNT]}"
fi echo " gPodder sync: ${CFG[GPODDER_ENABLE]}"
read -rp "Proceed? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
REQUIRED_DIRS=( # --- Base packages ---
"music" echo "Installing base packages..."
"podcasts" apt-get update -qq
"jingles" apt-get install -y -qq liquidsoap icecast2 jq curl unzip ca-certificates >/dev/null
"announcements"
"playlists"
"state"
"logs"
)
# ------------------------------------------------------- # --- Detect or install Java (>= 21) ---
# 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 ---"
JAVA_OK=false JAVA_OK=false
if command -v java >/dev/null 2>&1; then if command -v java &>/dev/null; then
JAVA_VER_LINE=$(java -version 2>&1 | head -1) JAVA_VER=$(java -version 2>&1 | head -1 | sed 's/.*"\([0-9]*\)\..*/\1/')
# Extract major version: handles both '"21.0.x"' and '1.8.0_xxx' styles if (( JAVA_VER >= 21 )); then
JAVA_MAJOR=$(echo "$JAVA_VER_LINE" | sed -nE 's/.*"([0-9]+)(\.[0-9]+)?".*/\1/p') echo "Found Java $JAVA_VER; reusing."
if [ -n "$JAVA_MAJOR" ] && [ "$JAVA_MAJOR" -ge 21 ]; then JAVA_OK=true
JAVA_OK=true fi
echo " Found suitable Java ($JAVA_VER_LINE)" fi
else if [[ "$JAVA_OK" != "true" ]]; then
echo " Java found but version too old ($JAVA_VER_LINE); installing OpenJDK 21." echo "Installing OpenJDK 21 headless..."
fi 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 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 fi
if [ "$JAVA_OK" != "true" ]; then # --- Register via update-alternatives ---
apt-get install -y openjdk-21-jre-headless for cmd in jruby bundle gem rake irb; do
echo " Installed OpenJDK 21." 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}")")"
fi # Simpler: just link the known bin paths
LINK_TARGET="$JRUBY_HOME_PINNED/bin/${cmd}"
# ------------------------------------------------------- if [[ -x "$LINK_TARGET" ]]; then
# 4. Detect / install JRuby ln -sf "$LINK_TARGET" "/usr/local/bin/${cmd}"
# ------------------------------------------------------- update-alternatives --install "/usr/local/bin/${cmd}" "${cmd}" "$LINK_TARGET" 100 || true
echo "" fi
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
fi
done 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 # --- Install gems one at a time (avoid OOM on low-RAM hosts) ---
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 ---"
export GEM_HOME="$GEM_HOME_LOCAL" export GEM_HOME="$GEM_HOME_LOCAL"
export GEM_PATH="$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" export JRUBY_OPTS="-J-Xmx1g -J-Xss512k"
install_gem() { echo "Installing bundler..."
local name="$1" "$JRUBY_BIN" -S gem install bundler --no-document 2>&1 | tail -1
echo " Installing gem: $name" echo "Installing nokogiri..."
if ! "$EFF_JRUBY_BIN" -S gem install "$name" --no-document; then "$JRUBY_BIN" -S gem install nokogiri --no-document 2>&1 | tail -1
echo "ERROR: Failed to install gem '$name'." echo "Installing sqlite3..."
exit 1 "$JRUBY_BIN" -S gem install sqlite3 --no-document 2>&1 | tail -1
fi 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 # --- Create directory structure ---
# separately so a single OOM can't take down the whole set. for d in music podcasts jingles announcements playlists state logs; do
install_gem bundler mkdir -p "$INSTALL_DIR/$d"
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
done done
if [ ${#MISSING[@]} -gt 0 ]; then # --- Generate config.json ---
echo " Created: ${MISSING[*]}" cat > "$INSTALL_DIR/config.json" <<EOF
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
{ {
"icecast": { "icecast": {
"host": "${ICECAST_HOST}", "host": "${CFG[ICE_HOST]}",
"port": ${ICECAST_PORT}, "port": ${CFG[ICE_PORT]},
"mount": "${ICECAST_MOUNT}", "mount": "${CFG[ICE_MOUNT]}",
"username": "${ICECAST_USERNAME}", "username": "${CFG[ICE_USER]}",
"password": "${ICECAST_PASSWORD}" "password": "${CFG[ICE_PASS]}"
}, },
"gpodder": { "gpodder": {
"enable": ${GPODDER_ENABLE}, "enable": ${CFG[GPODDER_ENABLE]},
"host": "${GPODDER_HOST}", "host": "${CFG[GPODDER_HOST]}",
"username": "${GPODDER_USERNAME}", "username": "${CFG[GPODDER_USER]}",
"password": "${GPODDER_PASSWORD}" "password": "${CFG[GPODDER_PASS]}"
} }
} }
EOF EOF
chmod 600 "$INSTALL_DIR/config.json"
chmod 600 "$CONFIG_FILE" # --- Ensure liquidsoap user exists ---
echo " Written: $CONFIG_FILE" if ! id liquidsoap &>/dev/null; then
useradd --system --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin liquidsoap
# -------------------------------------------------------
# 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 ---"
fi fi
chown -R liquidsoap:liquidsoap "$INSTALL_DIR"
# ------------------------------------------------------- # --- Write systemd service ---
# 10. Verify required project files exist cat > /etc/systemd/system/"$SERVICE_NAME".service <<EOF
# -------------------------------------------------------
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
[Unit] [Unit]
Description=Liquidsoap radio automation (${SERVICE_NAME}) Description=Liquidsoap radio station ($SERVICE_NAME)
After=network.target icecast2.service After=network.target icecast2.service
[Service] [Service]
Type=simple User=liquidsoap
User=${LIQUIDSOAP_USER} WorkingDirectory=$INSTALL_DIR
WorkingDirectory=${INSTALL_DIR}
Environment=GEM_HOME=${GEM_HOME_LOCAL} Environment=GEM_HOME=${GEM_HOME_LOCAL}
Environment=GEM_PATH=${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 Restart=on-failure
RestartSec=5 RestartSec=5
StandardOutput=append:${INSTALL_DIR}/logs/liquidsoap.log
StandardError=append:${INSTALL_DIR}/logs/liquidsoap.log
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
# ------------------------------------------------------- # --- Set up cron jobs (deduplicated) ---
# 12. Create cron jobs (hourly fetch + hourly playlist update) 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"
echo "--- Setting up cron jobs ---"
# Cron runs with a minimal PATH, so reference the absolute integrated path. (crontab -l 2>/dev/null | grep -v "fetch_podcasts.rb"; echo "0 * * * * $FETCH_CMD") | crontab -
RUBY_RUN="cd $INSTALL_DIR && GEM_HOME=$GEM_HOME_LOCAL GEM_PATH=$GEM_HOME_LOCAL /usr/local/bin/jruby -S bundle exec" (crontab -l 2>/dev/null | grep -v "update_playlists.rb"; echo "30 * * * * $UPDATE_CMD") | crontab -
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"
# --- Reload and enable ---
systemctl daemon-reload systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
# ------------------------------------------------------- echo
# 14. Final verification echo "=== Installation complete ==="
# -------------------------------------------------------
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 "Next steps:" echo "Next steps:"
echo " 1. Place your music in $INSTALL_DIR/music/" echo " 1. Drop MP3/M4A files into $INSTALL_DIR/music/"
echo " 2. Edit $SCHEDULE_FILE with your show/stream schedule" echo " 2. Edit $INSTALL_DIR/schedule.txt with your show schedule"
echo " 3. Review/edit $INSTALL_DIR/station.liq" echo " 3. Review $INSTALL_DIR/station.liq"
echo " 4. Test: sudo systemctl start icecast2 && sudo systemctl start ${SERVICE_NAME}" echo " 4. Start services:"
echo " 5. Check logs: tail -f $INSTALL_DIR/logs/liquidsoap.log" echo " sudo systemctl start icecast2"
echo "" echo " sudo systemctl start $SERVICE_NAME"
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"

View file

@ -1,459 +1,142 @@
#!/bin/bash #!/usr/bin/env bash
# # install_for_python - Provision the Python-based radio automation stack.
# install_for_python - Setup for liquidsoap radio automation stack # Must be run as root from within the target directory (e.g. /srv/radio/).
# Target: Linux Mint 22.3 (Ubuntu 24.04 Noble based) # Idempotent: safe to re-run.
#
# Usage: sudo ./install_for_python
# Must be run from within the target directory (e.g., /srv/audio/)
#
set -euo pipefail set -euo pipefail
# ------------------------------------------------------- INSTALL_DIR="$(pwd)"
# Determine install directory from script location
# -------------------------------------------------------
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
INSTALL_DIR="$(dirname "$SCRIPT_PATH")"
SERVICE_NAME="$(basename "$INSTALL_DIR")" SERVICE_NAME="$(basename "$INSTALL_DIR")"
LIQUIDSOAP_USER="liquidsoap" VENV="$INSTALL_DIR/.venv"
PYTHON_BIN="$VENV/bin/python"
echo "=== Radio Automation Installer ===" echo "=== Radio Automation Installer (Python) ==="
echo "Install directory: $INSTALL_DIR" echo "Install dir: $INSTALL_DIR"
echo "Service name: $SERVICE_NAME" echo "Service name: $SERVICE_NAME"
echo "" echo
# ------------------------------------------------------- # --- Prompt for config values (pre-fill from existing config.json) ---
# 0. Validate environment EXISTING_CONFIG="$INSTALL_DIR/config.json"
# ------------------------------------------------------- declare -A CFG
if [ "$(id -u)" -ne 0 ]; then CFG[ICE_HOST]="localhost"
echo "ERROR: This script must be run as root (sudo)." CFG[ICE_PORT]="7777"
exit 1 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 fi
if [ ! -d "$INSTALL_DIR" ]; then read -rp "Icecast host [$CFG[ICE_HOST]]: " v; CFG[ICE_HOST]=${v:-$CFG[ICE_HOST]}
echo "ERROR: Install directory $INSTALL_DIR does not exist." read -rp "Icecast port [$CFG[ICE_PORT]]: " v; CFG[ICE_PORT]=${v:-$CFG[ICE_PORT]}
exit 1 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 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 # --- Create directory structure ---
echo "ERROR: Cannot write to $INSTALL_DIR. Check permissions." for d in music podcasts jingles announcements playlists state logs; do
exit 1 mkdir -p "$INSTALL_DIR/$d"
fi
REQUIRED_DIRS=(
"music"
"podcasts"
"jingles"
"announcements"
"playlists"
"state"
"logs"
)
# -------------------------------------------------------
# Helper: prompt with default value
# -------------------------------------------------------
prompt() {
local var_name="$1"
local prompt_text="$2"
local default_value="${3:-}"
local hide_input="${4:-false}"
if [ -n "$default_value" ]; then
read -rp "$prompt_text [$default_value]: " input
if [ -z "$input" ]; then
eval "$var_name=$default_value"
else
eval "$var_name=\$input"
fi
else
if [ "$hide_input" = "true" ]; then
read -rsp "$prompt_text: " input
echo ""
eval "$var_name=\$input"
else
read -rp "$prompt_text: " input
eval "$var_name=\$input"
fi
fi
}
prompt_confirm() {
local var_name="$1"
local prompt_text="$2"
local default_value="${3:-no}"
read -rp "$prompt_text [$default_value]: " input
if [ -z "$input" ]; then
input="$default_value"
fi
case "$input" in
[Yy]* ) eval "$var_name=true" ;;
* ) eval "$var_name=false" ;;
esac
}
# -------------------------------------------------------
# 1. Interactive configuration
# -------------------------------------------------------
CONFIG_FILE="$INSTALL_DIR/config.json"
# Load existing config as defaults if present
EXISTING_CONFIG=false
if [ -f "$CONFIG_FILE" ]; then
EXISTING_CONFIG=true
echo "--- Loading existing config.json for defaults ---"
ICECAST_HOST_DEFAULT=$(jq -r '.icecast.host // "localhost"' "$CONFIG_FILE")
ICECAST_PORT_DEFAULT=$(jq -r '.icecast.port // 7777' "$CONFIG_FILE")
ICECAST_MOUNT_DEFAULT=$(jq -r '.icecast.mount // "/audio.mp3"' "$CONFIG_FILE")
ICECAST_USERNAME_DEFAULT=$(jq -r '.icecast.username // "source"' "$CONFIG_FILE")
GPODDER_ENABLE_DEFAULT=$(jq -r 'if .gpodder.enable == true then "yes" else "no" end' "$CONFIG_FILE")
GPODDER_HOST_DEFAULT=$(jq -r '.gpodder.host // "https://gpodder.net"' "$CONFIG_FILE")
GPODDER_USERNAME_DEFAULT=$(jq -r '.gpodder.username // ""' "$CONFIG_FILE")
else
ICECAST_HOST_DEFAULT="localhost"
ICECAST_PORT_DEFAULT="7777"
ICECAST_MOUNT_DEFAULT="/audio.mp3"
ICECAST_USERNAME_DEFAULT="source"
GPODDER_ENABLE_DEFAULT="no"
GPODDER_HOST_DEFAULT="https://gpodder.net"
GPODDER_USERNAME_DEFAULT=""
fi
echo ""
echo "--- Icecast Settings ---"
echo ""
ICECAST_HOST=""
ICECAST_PORT=""
ICECAST_MOUNT=""
ICECAST_USERNAME=""
ICECAST_PASSWORD=""
while true; do
prompt ICECAST_HOST "Icecast host" "$ICECAST_HOST_DEFAULT"
[[ "$ICECAST_HOST" =~ ^[a-zA-Z0-9._-]+$ ]] && break
echo "Invalid hostname. Try again."
done done
while true; do # --- Generate config.json ---
prompt ICECAST_PORT "Source port" "$ICECAST_PORT_DEFAULT" cat > "$INSTALL_DIR/config.json" <<EOF
[[ "$ICECAST_PORT" =~ ^[0-9]{1,5}$ ]] && (( ICECAST_PORT >= 1 )) && (( ICECAST_PORT <= 65535 )) && break
echo "Port must be a number between 1 and 65535. Try again."
done
while true; do
prompt ICECAST_MOUNT "Mount point" "$ICECAST_MOUNT_DEFAULT"
[[ "$ICECAST_MOUNT" == /* ]] && break
echo "Mount point must start with /. Try again."
done
prompt ICECAST_USERNAME "Source username" "$ICECAST_USERNAME_DEFAULT"
while true; do
prompt ICECAST_PASSWORD "Source password" "" "true"
[ -n "$ICECAST_PASSWORD" ] && break
echo "Password cannot be empty. Try again."
done
echo ""
echo "--- gPodder.net Sync Settings ---"
echo ""
GPODDER_ENABLE=false
if [ "$GPODDER_ENABLE_DEFAULT" = "yes" ]; then
prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "yes"
else
prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "no"
fi
GPODDER_HOST=""
GPODDER_USERNAME=""
GPODDER_PASSWORD=""
if [ "$GPODDER_ENABLE" = "true" ]; then
prompt GPODDER_HOST "gPodder host" "$GPODDER_HOST_DEFAULT"
while true; do
prompt GPODDER_USERNAME "gPodder username/email" "$GPODDER_USERNAME_DEFAULT"
[ -n "$GPODDER_USERNAME" ] && break
echo "Username cannot be empty. Try again."
done
while true; do
prompt GPODDER_PASSWORD "gPodder password" "" "true"
[ -n "$GPODDER_PASSWORD" ] && break
echo "Password cannot be empty. Try again."
done
fi
echo ""
echo "--- Summary ---"
echo " Icecast: ${ICECAST_HOST}:${ICECAST_PORT}${ICECAST_MOUNT} (user: ${ICECAST_USERNAME})"
echo " gPodder: $( [ "$GPODDER_ENABLE" = "true" ] && echo "enabled (${GPODDER_USERNAME}@${GPODDER_HOST})" || echo "disabled" )"
echo ""
CONFIRM_INSTALL=false
prompt_confirm CONFIRM_INSTALL "Proceed with these settings?" "yes"
if [ "$CONFIRM_INSTALL" != "true" ]; then
echo "Aborted by user."
exit 0
fi
# -------------------------------------------------------
# 2. System packages
# -------------------------------------------------------
echo ""
echo "--- Installing system packages ---"
apt-get update
apt-get install -y \
liquidsoap \
icecast2 \
jq \
python3 \
python3-pip \
python3-venv \
ffmpeg \
lame \
libtag1-dev \
curl \
ca-certificates
# -------------------------------------------------------
# 3. Python virtual environment
# -------------------------------------------------------
echo "--- Setting up Python virtualenv ---"
VENV_DIR="$INSTALL_DIR/.venv"
python3 -m venv "$VENV_DIR"
"$VENV_DIR/bin/pip" install --upgrade pip
"$VENV_DIR/bin/pip" install \
feedparser \
requests \
mutagen
PYTHON_BIN="$VENV_DIR/bin/python3"
# -------------------------------------------------------
# 4. Create and verify directory structure
# -------------------------------------------------------
echo "--- Creating directory structure ---"
MISSING=()
for dir in "${REQUIRED_DIRS[@]}"; do
full_path="$INSTALL_DIR/$dir"
if [ ! -d "$full_path" ]; then
mkdir -p "$full_path"
MISSING+=("$dir")
fi
done
if [ ${#MISSING[@]} -gt 0 ]; then
echo " Created: ${MISSING[*]}"
else
echo " All directories already present."
fi
FAILED=()
for dir in "${REQUIRED_DIRS[@]}"; do
if [ ! -d "$INSTALL_DIR/$dir" ]; then
FAILED+=("$dir")
fi
done
if [ ${#FAILED[@]} -gt 0 ]; then
echo "ERROR: Failed to create directories: ${FAILED[*]}"
exit 1
fi
# -------------------------------------------------------
# 5. Generate config.json from collected settings
# -------------------------------------------------------
echo "--- Generating config.json ---"
cat > "$CONFIG_FILE" << EOF
{ {
"icecast": { "icecast": {
"host": "${ICECAST_HOST}", "host": "${CFG[ICE_HOST]}",
"port": ${ICECAST_PORT}, "port": ${CFG[ICE_PORT]},
"mount": "${ICECAST_MOUNT}", "mount": "${CFG[ICE_MOUNT]}",
"username": "${ICECAST_USERNAME}", "username": "${CFG[ICE_USER]}",
"password": "${ICECAST_PASSWORD}" "password": "${CFG[ICE_PASS]}"
}, },
"gpodder": { "gpodder": {
"enable": ${GPODDER_ENABLE}, "enable": ${CFG[GPODDER_ENABLE]},
"host": "${GPODDER_HOST}", "host": "${CFG[GPODDER_HOST]}",
"username": "${GPODDER_USERNAME}", "username": "${CFG[GPODDER_USER]}",
"password": "${GPODDER_PASSWORD}" "password": "${CFG[GPODDER_PASS]}"
} }
} }
EOF EOF
chmod 600 "$INSTALL_DIR/config.json"
chmod 600 "$CONFIG_FILE" # --- Ensure liquidsoap user exists ---
echo " Written: $CONFIG_FILE" if ! id liquidsoap &>/dev/null; then
useradd --system --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin liquidsoap
# -------------------------------------------------------
# 6. Create schedule.txt template (if not present)
# -------------------------------------------------------
SCHEDULE_FILE="$INSTALL_DIR/schedule.txt"
if [ ! -f "$SCHEDULE_FILE" ]; then
echo "--- Creating schedule.txt template ---"
cat > "$SCHEDULE_FILE" << 'EOF'
# Radio Schedule
# Format: min hour dom mon dow TYPE TARGET [RUNLENGTH_SECONDS]
# TYPE: show | stream
# RUNLENGTH required for stream entries, ignored for show entries
#
# Examples:
# 0 8 * * 2 show hardcore_history
# 0 6 * * 1 stream http://example.org:8000/live.mp3 3600
EOF
else
echo "--- schedule.txt already exists, skipping ---"
fi fi
chown -R liquidsoap:liquidsoap "$INSTALL_DIR"
# ------------------------------------------------------- # --- Write systemd service ---
# 7. Verify required project files exist cat > /etc/systemd/system/"$SERVICE_NAME".service <<EOF
# -------------------------------------------------------
echo "--- Verifying project files ---"
PROJECT_FILES=("station.liq" "fetch_podcasts.py" "update_playlists.py")
FILE_ERRORS=()
for f in "${PROJECT_FILES[@]}"; do
if [ ! -f "$INSTALL_DIR/$f" ]; then
FILE_ERRORS+=("$f")
else
echo " Found: $f"
fi
done
if [ ${#FILE_ERRORS[@]} -gt 0 ]; then
echo "WARNING: Missing expected files: ${FILE_ERRORS[*]}"
echo " The service will not start until these are in place."
fi
# -------------------------------------------------------
# 8. Create systemd service named after the directory
# -------------------------------------------------------
echo "--- Creating systemd service: ${SERVICE_NAME}.service ---"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF
[Unit] [Unit]
Description=Liquidsoap radio automation (${SERVICE_NAME}) Description=Liquidsoap radio station ($SERVICE_NAME)
After=network.target icecast2.service After=network.target icecast2.service
[Service] [Service]
Type=simple User=liquidsoap
User=${LIQUIDSOAP_USER} WorkingDirectory=$INSTALL_DIR
WorkingDirectory=${INSTALL_DIR} ExecStart=/usr/bin/liquidsoap $INSTALL_DIR/station.liq
ExecStart=/usr/bin/liquidsoap ${INSTALL_DIR}/station.liq
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
StandardOutput=append:${INSTALL_DIR}/logs/liquidsoap.log
StandardError=append:${INSTALL_DIR}/logs/liquidsoap.log
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
# ------------------------------------------------------- # --- Set up cron jobs (deduplicated) ---
# 9. Create cron jobs (hourly fetch + hourly playlist update) 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"
echo "--- Setting up cron jobs ---"
FETCH_CRON="0 * * * * cd $INSTALL_DIR && $PYTHON_BIN fetch_podcasts.py >> $INSTALL_DIR/logs/fetch.log 2>&1" (crontab -l 2>/dev/null | grep -v "fetch_podcasts.py"; echo "$CRON_FETCH") | crontab -
UPDATE_CRON="30 * * * * cd $INSTALL_DIR && $PYTHON_BIN update_playlists.py >> $INSTALL_DIR/logs/update.log 2>&1" (crontab -l 2>/dev/null | grep -v "update_playlists.py"; echo "$CRON_UPDATE") | crontab -
# 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"
# --- Reload and enable ---
systemctl daemon-reload systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
# ------------------------------------------------------- echo
# 11. Final verification echo "=== Installation complete ==="
# -------------------------------------------------------
echo ""
echo "=== Final Verification ==="
ALL_OK=true
for dir in "${REQUIRED_DIRS[@]}"; do
if [ -d "$INSTALL_DIR/$dir" ] && [ -r "$INSTALL_DIR/$dir" ] && [ -w "$INSTALL_DIR/$dir" ]; then
printf " [OK] %-20s\n" "$dir/"
else
printf " [FAIL] %-20s (missing or inaccessible)\n" "$dir/"
ALL_OK=false
fi
done
if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then
echo " [OK] Service file: ${SERVICE_NAME}.service"
else
echo " [FAIL] Service file not created"
ALL_OK=false
fi
if [ -f "$CONFIG_FILE" ]; then
if jq empty "$CONFIG_FILE" 2>/dev/null; then
echo " [OK] Config: config.json (valid JSON)"
else
echo " [FAIL] Config: config.json (invalid JSON)"
ALL_OK=false
fi
else
echo " [FAIL] Config file missing"
ALL_OK=false
fi
if [ -f "$SCHEDULE_FILE" ]; then
echo " [OK] Schedule: schedule.txt"
else
echo " [FAIL] Schedule file missing"
ALL_OK=false
fi
echo ""
if [ "$ALL_OK" = true ]; then
echo "=== Installation Complete ==="
else
echo "=== Installation Finished With Errors ==="
echo "Review the [FAIL] items above before starting the service."
fi
echo ""
echo "Next steps:" echo "Next steps:"
echo " 1. Place your music in $INSTALL_DIR/music/" echo " 1. Drop MP3/M4A files into $INSTALL_DIR/music/"
echo " 2. Edit $SCHEDULE_FILE with your show/stream schedule" echo " 2. Edit $INSTALL_DIR/schedule.txt with your show schedule"
echo " 3. Review/edit $INSTALL_DIR/station.liq" echo " 3. Review $INSTALL_DIR/station.liq"
echo " 4. Test: sudo systemctl start icecast2 && sudo systemctl start ${SERVICE_NAME}" echo " 4. Start services:"
echo " 5. Check logs: tail -f $INSTALL_DIR/logs/liquidsoap.log" echo " sudo systemctl start icecast2"
echo "" echo " sudo systemctl start $SERVICE_NAME"
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"

View file

@ -1,116 +1,108 @@
# station.liq - Main liquidsoap configuration #!/usr/bin/env liquidsoap
# All paths are resolved relative to this file's location.
# Move the whole directory tree and nothing else needs changing.
# ------------------------------------------------------- # ---------------------------------------------------------------
# Resolve root directory from script location # station.liq - Liquidsoap configuration for radio automation
# ------------------------------------------------------- # Auto-detects its own location so all paths are relative.
let ROOT = configure.bindir() # ---------------------------------------------------------------
let MUSIC_DIR = ROOT ^ "/music" configure.bindir()
let PODCASTS_DIR = ROOT ^ "/podcasts"
let PLAYLISTS_DIR = ROOT ^ "/playlists"
let SCHEDULE_FILE = ROOT ^ "/schedule.txt"
let CONFIG_FILE = ROOT ^ "/config.json"
# ------------------------------------------------------- set("log.file.path", "#{bindir()}/logs/liquidsoap.log")
# Load Icecast credentials from config.json set("log.stdout", true)
# ------------------------------------------------------- set("log.level", 3)
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)
# ------------------------------------------------------- # --- Load Icecast credentials from config.json ---
# Background music source let json.parse (cfg : {
# ------------------------------------------------------- icecast: {
let music = host: string,
playlist.recursive( port: int,
mode="random", mount: string,
duration=3600, username: string,
path=MUSIC_DIR, password: string
extensions=["mp3", "m4a", "ogg", "flac"] },
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: request queue fed by schedule.txt ---
# Scheduled content via request queue sched_queue = request.queue(id="scheduler")
# -------------------------------------------------------
def q = ref []
def push_request(req) = # --- Jingles / announcements (optional) ---
q := req :: (!q) jingle_dir = "#{bindir()}/jingles"
announce_dir = "#{bindir()}/announcements"
# Build a show source from its .pls file (written by update_playlists.py) def has_audio(dir) =
def show_source(slug) = try
let pls_path = PLAYLISTS_DIR ^ "/" ^ slug ^ ".pls" let l = list.filter(fun(f) -> regexp("\\.(mp3|m4a)$").test(f), ls.dir(dir))
if file.test(pls_path) then list.length(l) > 0
log("# Playing scheduled show: #{slug}") catch _ do
Some(request.create(pls_path)) false
end
end
jingles_src =
if has_audio(jingle_dir) then
mksafe(request.cue(playlist(recurse=true, pattern="\\.(mp3|m4a)$", jingle_dir)))
else else
log("! No playlist found for show: #{slug}, skipping") null()
None
end end
# Parse schedule.txt and register cron-triggered tasks announcements_src =
def load_schedule() = if has_audio(announce_dir) then
try mksafe(request.cue(playlist(recurse=true, pattern="\\.(mp3|m4a)$", announce_dir)))
if not(file.test(SCHEDULE_FILE)) then else
log("WARNING: schedule.txt not found at #{SCHEDULE_FILE}") null()
else end
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}")
end
end
) lines
log("Schedule loaded from #{SCHEDULE_FILE}")
with e ->
log("ERROR loading schedule: #{e}")
# Load schedule once at startup # --- Assemble the stream ---
thread.run(at=now(), fun () -> load_schedule()) primary = fallback(track_sensitive=false, [sched_queue, music_playlist])
# ------------------------------------------------------- content =
# Output: scheduled content over background music fallback 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( output.icecast(
%mp3(bitrate=128), %mp3(bitrate=128, samplerate=44100, stereo=true),
host=ic_host, host=ic_host,
port=ic_port, port=ic_port,
user=ic_user, user=ic_username,
password=ic_pass, password=ic_password,
mount=ic_mount, mount=ic_mount,
fallback=music, name="Radio Station",
request.queue(q) description="Automated internet radio",
genre="Various",
public=true,
radio
) )

View file

@ -1,26 +1,162 @@
#!/usr/bin/env python3 #!/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 argparse
import sys
import json import json
import logging
import re
import sqlite3 import sqlite3
import sys
from pathlib import Path from pathlib import Path
from datetime import datetime
AUDIO_ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
PLAYLISTS_DIR = AUDIO_ROOT / "playlists" STATE_DIR = ROOT / "state"
STATE_DB = AUDIO_ROOT / "state" / "played.db" SUBS_DB = STATE_DIR / "subscriptions.db"
SUBS_DB = AUDIO_ROOT / "state" / "subscriptions.db" PLAYED_DB = STATE_DIR / "played.db"
SHOWS_DIR = AUDIO_ROOT / "podcasts" PODCASTS_DIR = ROOT / "podcasts"
PLAYLISTS_DIR = ROOT / "playlists"
LOGS_DIR = ROOT / "logs"
AUDIO_EXTS = {".mp3", ".m4a"}
def get_played_db(): logging.basicConfig(
STATE_DB.parent.mkdir(parents=True, exist_ok=True) level=logging.INFO,
conn = sqlite3.connect(str(STATE_DB)) format="%(asctime)s [%(levelname)s] %(message)s",
conn.execute("""CREATE TABLE IF NOT EXISTS played ( handlers=[
filename TEXT PRIMARY KEY, logging.FileHandler(LOGS_DIR / "update.log"),
show TEXT, logging.StreamHandler(sys.stdout),
played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ],
)""") )
log = logging.getLogger("update_playlists")
def open_subs_db():
conn = sqlite3.connect(SUBS_DB)
conn.row_factory = sqlite3.Row
return conn 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 #!/usr/bin/env jruby
# frozen_string_literal: true # 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 "json"
require "sqlite3" require "sqlite3"
require "fileutils" require "fileutils"
require "time" require "optparse"
AUDIO_ROOT = Pathname.new(File.expand_path(__dir__)) module RadioAutomation
PLAYLISTS_DIR = AUDIO_ROOT.join("playlists") ROOT = File.expand_path("..", __dir__)
STATE_DB = AUDIO_ROOT.join("state/played.db") STATE_DIR = File.join(ROOT, "state")
SUBS_DB = AUDIO_ROOT.join("state/subscriptions.db") SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
SHOWS_DIR = AUDIO_ROOT.join("podcasts") 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 def self.log_info(msg)
class PlaylistUpdater puts "#{Time.now.iso8601} [INFO] #{msg}"
def initialize append_log("update.log", msg)
FileUtils.mkdir_p(PLAYLISTS_DIR) end
FileUtils.mkdir_p(STATE_DB.dirname)
@played_db = connect_played_db
@subs_db = connect_subs_db
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 def self.open_subs_db
conn = SQLite3::Database.new(STATE_DB.to_s) db = SQLite3::Database.new(SUBS_DB)
conn.execute(<<~SQL) db.results_as_hash = true
CREATE TABLE IF NOT EXISTS played ( db
filename TEXT PRIMARY KEY, end
show TEXT,
played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
SQL
conn
end
def connect_subs_db def self.open_played_db
conn = SQLite3::Database.new(SUBS_DB.to_s) db = SQLite3::Database.new(PLAYED_DB)
conn db.results_as_hash = true
end db.execute <<-SQL
CREATE TABLE IF NOT EXISTS episodes (
# Build a .pls for each show: pick an unplayed episode, mark it played. id INTEGER PRIMARY KEY AUTOINCREMENT,
def regenerate_all show_slug TEXT NOT NULL,
shows = subs_db.execute("SELECT name, slug FROM shows ORDER BY name") guid TEXT NOT NULL,
if shows.empty? title TEXT,
puts "No shows registered." file_path TEXT,
return duration_seconds INTEGER,
end played_at TEXT,
UNIQUE(show_slug, guid)
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_db.commit SQL
db
end
puts " #{slug}: queued #{File.basename(chosen)}" def self.find_audio_files(directory)
true 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
def self.select_unplayed_episode(slug, played_db)
files = find_audio_files(File.join(PODCASTS_DIR, slug))
return nil if files.empty?
played_rows = played_db.query_all(
"SELECT file_path, played_at FROM episodes WHERE show_slug = ? AND played_at IS NOT NULL", slug
)
played_map = played_rows.each_with_object({}) { |r, h| h[r["file_path"]] = r["played_at"] }
unplayed = files.reject { |f| played_map.key?(f) }
return unplayed.first if unplayed.any?
if played_map.any?
played_map.min_by { |_path, ts| ts.to_s }[0]
else
files.first
end end
end
def lru_episode(candidates, slug) def self.write_pls(filepath, out_path)
played_rows = played_db.execute( abs = File.absolute_path(filepath)
"SELECT filename, played_at FROM played WHERE show=? ORDER BY played_at ASC", slug content = "[playlist]\nFile1=#{abs}\nTitle1=Radio Episode\nLength1=-1\nNumberOfEntries=1\nVersion=2\n"
) FileUtils.mkdir_p(File.dirname(out_path))
played_map = played_rows.to_h { |fname, ts| [File.basename(fname), ts] } File.write(out_path, content)
candidates.min_by { |c| played_map[File.basename(c)] || Time.at(0) } end
end
def dump_json def self.mark_as_played(slug, filepath, played_db)
shows = subs_db.execute("SELECT name, slug FROM shows ORDER BY name") played_db.execute(
out = {} "UPDATE episodes SET played_at = datetime('now') WHERE show_slug = ? AND file_path = ?",
shows.each do |_name, slug| [slug, filepath]
show_dir = SHOWS_DIR.join(slug) )
next unless Dir.exist?(show_dir) end
files = Dir.glob(show_dir.join("**/*.{mp3,m4a,ogg,flac}")).size def self.update_all
played = played_db.get_first_row("SELECT COUNT(*) FROM played WHERE show=?", slug)[0] subs_db = open_subs_db
out[slug] = { total_files: files, played: played } 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 end
puts JSON.pretty_generate(out) 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 end
def close subs_db.close
played_db&.close played_db.close
subs_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 end
end end
require "set" RadioAutomation.main
def main
u = Radio::PlaylistUpdater.new
if ARGV.include?("--json")
u.dump_json
else
u.regenerate_all
end
ensure
u&.close
end
main