mirror of
https://github.com/mistergibson/radio.git
synced 2026-09-08 22:09:51 -07:00
Minor tweaks
This commit is contained in:
parent
0c5f864007
commit
952c9ae5a9
9 changed files with 1408 additions and 2024 deletions
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal 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/
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
[](https://firstdonoharm.dev/version/3/0/full.html)
|
||||
|
||||
# Radio Automation Stack
|
||||
|
||||
A self-hosted internet radio automation system for Linux Mint 22.3 (Ubuntu 24.04 Noble). It continuously plays background music, interrupts it with scheduled podcast shows and external streams, downloads new podcast episodes from RSS feeds, and manages playback history — all fed to an Icecast server via liquidsoap.
|
||||
|
|
@ -48,7 +50,6 @@ The moving parts:
|
|||
| `config.json` | Credentials and connection settings | No (secret) |
|
||||
| `schedule.txt` | Human-edited cron schedule of shows/streams | Yes |
|
||||
| `station.liq` | liquidsoap configuration | Yes |
|
||||
| `Gemfile` | Ruby gem dependencies (Ruby stack) | Yes |
|
||||
|
||||
## Configuration: `config.json`
|
||||
|
||||
|
|
@ -214,7 +215,7 @@ For each show, the updater scans `podcasts/<slug>/` recursively for audio files,
|
|||
The liquidsoap program itself. Not invoked directly — it runs under the systemd service. Key behaviors:
|
||||
|
||||
- Resolves all paths relative to its own location via `configure.bindir()`, so the whole tree can be relocated without editing the file.
|
||||
- Loads Icecast credentials from `config.json`.
|
||||
- Loads Icecast credentials from `config.json` using an annotated `json.parse` binding.
|
||||
- Maintains a continuous random background-music playlist drawn from `music/`.
|
||||
- Uses a request queue as the primary source: when a scheduled show or stream is triggered, it plays ahead of the music fallback; when the queue drains, background music resumes.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,459 +1,394 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fetch podcast episodes from RSS feeds, manage subscriptions, and download audio."""
|
||||
"""
|
||||
fetch_podcasts.py - Podcast subscription management and episode fetching
|
||||
for the liquidsoap radio automation stack.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import hashlib
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import quote
|
||||
|
||||
import feedparser
|
||||
import requests
|
||||
|
||||
AUDIO_ROOT = Path(__file__).resolve().parent
|
||||
DB_PATH = AUDIO_ROOT / "state" / "subscriptions.db"
|
||||
DOWNLOAD_DIR = AUDIO_ROOT / "podcasts"
|
||||
CONFIG_PATH = AUDIO_ROOT / "config.json"
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
CONFIG_PATH = ROOT / "config.json"
|
||||
STATE_DIR = ROOT / "state"
|
||||
SUBS_DB = STATE_DIR / "subscriptions.db"
|
||||
PLAYED_DB = STATE_DIR / "played.db"
|
||||
PODCASTS_DIR = ROOT / "podcasts"
|
||||
LOGS_DIR = ROOT / "logs"
|
||||
|
||||
AUDIO_EXTS = {".mp3", ".m4a"}
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(LOGS_DIR / "fetch.log"),
|
||||
logging.StreamHandler(sys.stdout),
|
||||
],
|
||||
)
|
||||
log = logging.getLogger("fetch_podcasts")
|
||||
|
||||
def log_error(msg):
|
||||
log.error(msg)
|
||||
|
||||
def load_config():
|
||||
with open(CONFIG_PATH, "r") as f:
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def get_db():
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS shows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
feed_url TEXT UNIQUE NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
opml_import INTEGER DEFAULT 0
|
||||
)""")
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS seen (
|
||||
url TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
show_slug TEXT,
|
||||
duration_sec INTEGER DEFAULT 0,
|
||||
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)""")
|
||||
# Migrate existing databases that lack the new column
|
||||
cols = [row[1] for row in conn.execute("PRAGMA table_info(shows)").fetchall()]
|
||||
if "opml_import" not in cols:
|
||||
conn.execute("ALTER TABLE shows ADD COLUMN opml_import INTEGER DEFAULT 0")
|
||||
def open_subs_db():
|
||||
conn = sqlite3.connect(SUBS_DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS shows (
|
||||
slug TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
feed_url TEXT NOT NULL UNIQUE,
|
||||
source TEXT DEFAULT 'manual',
|
||||
opml_import INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
def open_played_db():
|
||||
conn = sqlite3.connect(PLAYED_DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
show_slug TEXT NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
title TEXT,
|
||||
file_path TEXT,
|
||||
duration_seconds INTEGER,
|
||||
played_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(show_slug, guid)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
def sanitize_slug(name):
|
||||
"""Convert a show name to a filesystem-safe slug."""
|
||||
slug = name.lower().strip()
|
||||
slug = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in slug)
|
||||
slug = "_".join(slug.split("_"))
|
||||
return slug[:80]
|
||||
def slugify(name):
|
||||
s = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
|
||||
return s[:60] or "show"
|
||||
|
||||
def gpodder_sync(cfg):
|
||||
g = cfg["gpodder"]
|
||||
base = g["host"].rstrip("/")
|
||||
username = g["username"]
|
||||
password = g["password"]
|
||||
url = f"{base}/subscriptions/{quote(username, safe='')}.opml"
|
||||
|
||||
def register_shows_from_opml_xml(xml_bytes, db, opml_import=False):
|
||||
"""Register shows from OPML XML data.
|
||||
print(f"--- Syncing subscriptions from {base} ---")
|
||||
print(f"Fetching subscriptions for '{username}'...")
|
||||
|
||||
Uses upsert semantics: if a show with the same feed_url already exists,
|
||||
no duplicate row is created. Its opml_import flag is upgraded to 1 if
|
||||
this import marks it as such (protecting it from gpodder pruning).
|
||||
"""
|
||||
root = ET.fromstring(xml_bytes)
|
||||
added = 0
|
||||
updated_flag = 0
|
||||
skipped = 0
|
||||
resp = requests.get(
|
||||
url,
|
||||
auth=(username, password),
|
||||
headers={"User-Agent": "radio-automation/1.0"},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
body = resp.text
|
||||
if not body.strip():
|
||||
log_error("gPodder sync returned an empty body.")
|
||||
return []
|
||||
return parse_opml(body)
|
||||
elif resp.status_code == 401:
|
||||
log_error("gPodder sync failed: 401 Unauthorized. Check username/password in config.json.")
|
||||
elif resp.status_code == 404:
|
||||
log_error("gPodder sync failed: 404 Not Found. User may not exist or has no subscriptions.")
|
||||
elif resp.status_code == 400:
|
||||
log_error("gPodder sync failed: 400 Bad Request.")
|
||||
else:
|
||||
log_error(f"gPodder sync failed: unexpected response {resp.status_code}: {resp.text[:200]}")
|
||||
return []
|
||||
|
||||
def parse_opml(xml_string):
|
||||
shows = []
|
||||
try:
|
||||
root = ET.fromstring(xml_string)
|
||||
except ET.ParseError as e:
|
||||
log_error(f"Failed to parse OPML XML: {e}")
|
||||
return []
|
||||
for outline in root.iter("outline"):
|
||||
xml_url = outline.get("xmlUrl", "")
|
||||
if not xml_url:
|
||||
continue
|
||||
otype = outline.get("type", "")
|
||||
if otype and otype != "rss":
|
||||
feed_url = (outline.attrib.get("xmlUrl") or "").strip()
|
||||
name = (outline.attrib.get("text") or "").strip()
|
||||
if not feed_url or not re.match(r"^https?://", feed_url):
|
||||
continue
|
||||
shows.append({"name": name, "feed_url": feed_url})
|
||||
return shows
|
||||
|
||||
show_name = outline.get("text") or outline.get("title") or "Unknown Show"
|
||||
slug = sanitize_slug(show_name)
|
||||
flag = 1 if opml_import else 0
|
||||
|
||||
existing = db.execute(
|
||||
"SELECT id, opml_import FROM shows WHERE feed_url=?", (xml_url,)
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
existing_id, existing_flag = existing
|
||||
if flag == 1 and existing_flag == 0:
|
||||
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:
|
||||
def register_remote_shows(remote_shows):
|
||||
db = open_subs_db()
|
||||
added = 0
|
||||
for show in remote_shows:
|
||||
slug = slugify(show["name"])
|
||||
existing = db.execute("SELECT slug FROM shows WHERE slug = ?", (slug,)).fetchone()
|
||||
if existing is None:
|
||||
db.execute(
|
||||
"INSERT INTO shows (name, feed_url, slug, opml_import) VALUES (?, ?, ?, ?)",
|
||||
(show_name, xml_url, slug, flag)
|
||||
"INSERT INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'gpodder', 0)",
|
||||
(slug, show["name"], show["feed_url"]),
|
||||
)
|
||||
log.info("Registered new show: %s (%s)", show["name"], slug)
|
||||
added += 1
|
||||
print(f" Registered: {show_name} -> {slug}")
|
||||
|
||||
db.commit()
|
||||
|
||||
if added:
|
||||
print(f" Added {added} new show(s).")
|
||||
if updated_flag:
|
||||
print(f" Upgraded {updated_flag} show(s) to protected.")
|
||||
if skipped:
|
||||
print(f" Skipped {skipped} duplicate(s).")
|
||||
db.close()
|
||||
return added
|
||||
|
||||
|
||||
def import_opml_file(filepath):
|
||||
"""Import shows from a local OPML file."""
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
xml_data = f.read()
|
||||
except FileNotFoundError:
|
||||
print(f"ERROR: File not found: {filepath}")
|
||||
sys.exit(1)
|
||||
|
||||
db = get_db()
|
||||
count = register_shows_from_opml_xml(xml_data, db, opml_import=True)
|
||||
total = db.execute("SELECT COUNT(*) FROM shows").fetchone()[0]
|
||||
db.close()
|
||||
|
||||
print(f"\nImport complete. {count} new show(s) added. Total registered: {total}")
|
||||
|
||||
|
||||
def sync_gpoddernet():
|
||||
"""Sync subscriptions from gpodder.net, register new shows, prune removed ones."""
|
||||
cfg = load_config()
|
||||
gp = cfg["gpodder"]
|
||||
|
||||
if not gp.get("enable", False):
|
||||
print("gpodder.net sync is disabled in config.json.")
|
||||
return
|
||||
|
||||
if not gp.get("username") or not gp.get("password"):
|
||||
print("ERROR: gpodder.username/gpodder.password not set in config.json")
|
||||
sys.exit(1)
|
||||
|
||||
url = f"{gp['host']}/subscriptions/{gp['username']}.opml"
|
||||
print(f"Fetching subscriptions from {gp['host']} for '{gp['username']}'...")
|
||||
|
||||
resp = requests.get(url, auth=(gp["username"], gp["password"]), timeout=30)
|
||||
|
||||
if resp.status_code == 401:
|
||||
print("ERROR: Authentication failed. Check username/password in config.json.")
|
||||
sys.exit(1)
|
||||
elif resp.status_code != 200:
|
||||
print(f"ERROR: Unexpected response {resp.status_code}: {resp.text[:200]}")
|
||||
sys.exit(1)
|
||||
|
||||
db = get_db()
|
||||
|
||||
# Collect all feed URLs from the current subscription list
|
||||
root = ET.fromstring(resp.content)
|
||||
gp_feed_urls = set()
|
||||
for outline in root.iter("outline"):
|
||||
fu = outline.get("xmlUrl", "")
|
||||
if fu:
|
||||
gp_feed_urls.add(fu)
|
||||
|
||||
# Register any new shows
|
||||
count = register_shows_from_opml_xml(resp.content, db, opml_import=False)
|
||||
total = db.execute("SELECT COUNT(*) FROM shows").fetchone()[0]
|
||||
|
||||
# Prune shows that were unsubscribed
|
||||
prune_removed_shows(gp_feed_urls, db)
|
||||
|
||||
db.close()
|
||||
print(f"\nSync complete. {count} new, total registered: {total}")
|
||||
|
||||
|
||||
def prune_removed_shows(gp_feed_urls, db):
|
||||
"""Remove shows not in current gpodder list (unless opml_import=1)."""
|
||||
rows = db.execute(
|
||||
"SELECT slug, feed_url, name FROM shows WHERE opml_import = 0"
|
||||
def prune_stale_shows(remote_shows):
|
||||
db = open_subs_db()
|
||||
remote_slugs = {slugify(s["name"]) for s in remote_shows}
|
||||
stale = db.execute(
|
||||
"SELECT slug, name FROM shows WHERE source = 'gpodder' AND opml_import = 0"
|
||||
).fetchall()
|
||||
|
||||
to_remove = []
|
||||
for slug, feed_url, name in rows:
|
||||
if feed_url not in gp_feed_urls:
|
||||
to_remove.append((slug, name))
|
||||
|
||||
if not to_remove:
|
||||
return
|
||||
|
||||
for slug, name in to_remove:
|
||||
print(f" Removing unsubscribed show: {name} ({slug})")
|
||||
db.execute("DELETE FROM seen WHERE show_slug=?", (slug,))
|
||||
db.execute("DELETE FROM shows WHERE slug=?", (slug))
|
||||
show_dir = DOWNLOAD_DIR / slug
|
||||
if show_dir.exists():
|
||||
shutil.rmtree(show_dir)
|
||||
print(f" Deleted directory: {show_dir}")
|
||||
|
||||
db.commit()
|
||||
print(f" Pruned {len(to_remove)} removed show(s).")
|
||||
|
||||
|
||||
def delete_show(slug):
|
||||
"""Manually delete a show and all associated data."""
|
||||
db = get_db()
|
||||
row = db.execute("SELECT name FROM shows WHERE slug=?", (slug,)).fetchone()
|
||||
if not row:
|
||||
print(f"No show found with slug '{slug}'.")
|
||||
db.close()
|
||||
return
|
||||
|
||||
name = row[0]
|
||||
print(f"Deleting show: {name} ({slug})")
|
||||
db.execute("DELETE FROM seen WHERE show_slug=?", (slug,))
|
||||
db.execute("DELETE FROM shows WHERE slug=?", (slug))
|
||||
removed = 0
|
||||
for row in stale:
|
||||
if row["slug"] not in remote_slugs:
|
||||
remove_show_data(row["slug"])
|
||||
db.execute("DELETE FROM shows WHERE slug = ?", (row["slug"],))
|
||||
log.info("Pruned stale show: %s (%s)", row["name"], row["slug"])
|
||||
removed += 1
|
||||
db.commit()
|
||||
db.close()
|
||||
return removed
|
||||
|
||||
show_dir = DOWNLOAD_DIR / slug
|
||||
if show_dir.exists():
|
||||
shutil.rmtree(show_dir)
|
||||
print(f"Deleted directory: {show_dir}")
|
||||
|
||||
pls_file = AUDIO_ROOT / "playlists" / f"{slug}.pls"
|
||||
if pls_file.exists():
|
||||
pls_file.unlink()
|
||||
print(f"Deleted playlist: {pls_file}")
|
||||
|
||||
print("Done.")
|
||||
|
||||
|
||||
def add_show(feed_url):
|
||||
"""Register a new show from a feed URL and fetch its initial episodes."""
|
||||
print(f"Fetching feed: {feed_url}")
|
||||
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
|
||||
def extract_duration(entry):
|
||||
dur = entry.get("media_duration") or entry.get("duration")
|
||||
if dur:
|
||||
try:
|
||||
raw_len = str(enclosure.get("length", "0"))
|
||||
if ":" in raw_len:
|
||||
parts = raw_len.split(":")
|
||||
duration_sec = int(parts[-1])
|
||||
else:
|
||||
duration_sec = int(raw_len)
|
||||
except (ValueError, IndexError):
|
||||
return int(float(dur))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
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
|
||||
safe_title = "".join(c if c.isalnum() or c in ("-", "_", " ") else "_" for c in title)
|
||||
safe_title = safe_title.strip()[:120]
|
||||
ext = ".mp3"
|
||||
if "ogg" in enclosure.get("type", "").lower():
|
||||
ext = ".ogg"
|
||||
elif "m4a" in enclosure.get("type", "").lower() or "aac" in enclosure.get("type", "").lower():
|
||||
ext = ".m4a"
|
||||
def fetch_feed(feed_url):
|
||||
try:
|
||||
parsed = feedparser.parse(feed_url)
|
||||
except Exception as e:
|
||||
log_error(f"Feed parse error for {feed_url}: {e}")
|
||||
return None
|
||||
if parsed.bozo and not parsed.entries:
|
||||
log_error(f"Bozo feed (no entries) for {feed_url}: {parsed.bozo_exception}")
|
||||
return None
|
||||
return parsed
|
||||
|
||||
filepath = show_dir / f"{safe_title}{ext}"
|
||||
|
||||
if filepath.exists():
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO seen (url, title, show_slug, duration_sec) VALUES (?, ?, ?, ?)",
|
||||
(ep_url, title, slug, duration_sec)
|
||||
)
|
||||
db.commit()
|
||||
continue
|
||||
|
||||
# Download
|
||||
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):
|
||||
def download_episode(url, dest_dir, filename):
|
||||
dest = dest_dir / filename
|
||||
if dest.exists():
|
||||
return str(dest)
|
||||
try:
|
||||
with requests.get(url, stream=True, timeout=120) as r:
|
||||
r.raise_for_status()
|
||||
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||
with open(tmp, "wb") as f:
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
tmp.rename(dest)
|
||||
return str(dest)
|
||||
except requests.RequestException as e:
|
||||
log_error(f"Download failed for {url}: {e}")
|
||||
return None
|
||||
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO seen (url, title, show_slug, duration_sec) VALUES (?, ?, ?, ?)",
|
||||
(ep_url, title, slug, duration_sec)
|
||||
)
|
||||
db.commit()
|
||||
new_count += 1
|
||||
def safe_filename(title, fallback):
|
||||
name = re.sub(r"[^\w\s.-]", "", title or "").strip().replace(" ", "_")
|
||||
return (name[:120] or fallback) + ".mp3"
|
||||
|
||||
def fetch_show_episodes(slug, name, feed_url):
|
||||
dest_dir = PODCASTS_DIR / slug
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
parsed = fetch_feed(feed_url)
|
||||
if parsed is None:
|
||||
return 0
|
||||
played_db = open_played_db()
|
||||
seen = {
|
||||
row["guid"]
|
||||
for row in played_db.execute("SELECT guid FROM episodes WHERE show_slug = ?", (slug,))
|
||||
}
|
||||
subs_db = open_subs_db()
|
||||
new_count = 0
|
||||
for entry in parsed.entries:
|
||||
guid = entry.get("id") or entry.get("link") or entry.get("title", "")
|
||||
if guid in seen:
|
||||
continue
|
||||
enclosures = entry.get("enclosures") or []
|
||||
if not enclosures:
|
||||
continue
|
||||
audio_url = enclosures[0].get("href")
|
||||
if not audio_url:
|
||||
continue
|
||||
title = entry.get("title", "untitled")
|
||||
filename = safe_filename(title, guid[-20:])
|
||||
file_path = download_episode(audio_url, dest_dir, filename)
|
||||
if file_path is None:
|
||||
continue
|
||||
duration = extract_duration(entry)
|
||||
played_db.execute(
|
||||
"INSERT OR IGNORE INTO episodes (show_slug, guid, title, file_path, duration_seconds, played_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, NULL)",
|
||||
(slug, guid, title, file_path, duration),
|
||||
)
|
||||
new_count += 1
|
||||
log.info(" New episode: %s [%s]", title, filename)
|
||||
played_db.commit()
|
||||
played_db.close()
|
||||
subs_db.close()
|
||||
return new_count
|
||||
|
||||
def fetch_all_episodes():
|
||||
db = open_subs_db()
|
||||
shows = db.execute("SELECT slug, name, feed_url FROM shows ORDER BY name").fetchall()
|
||||
db.close()
|
||||
total_new = 0
|
||||
for show in shows:
|
||||
log.info("--- Fetching: %s (%s) ---", show["name"], show["slug"])
|
||||
try:
|
||||
n = fetch_show_episodes(show["slug"], show["name"], show["feed_url"])
|
||||
total_new += n
|
||||
except Exception as e:
|
||||
print(f"[{show_name}] FAILED to download '{title}': {e}")
|
||||
|
||||
if new_count:
|
||||
print(f"[{show_name}] Downloaded {new_count} new episode(s).")
|
||||
else:
|
||||
print(f"[{show_name}] No new episodes.")
|
||||
|
||||
log_error(f"Unexpected error fetching {show['slug']}: {e}")
|
||||
log.info("=== Fetch complete: %d new episode(s) ===", total_new)
|
||||
|
||||
def list_shows(detail=False):
|
||||
"""List all registered shows."""
|
||||
db = get_db()
|
||||
rows = db.execute("SELECT name, slug, feed_url, opml_import FROM shows ORDER BY name").fetchall()
|
||||
|
||||
db = open_subs_db()
|
||||
rows = db.execute(
|
||||
"SELECT slug, name, feed_url, source, opml_import FROM shows ORDER BY name"
|
||||
).fetchall()
|
||||
db.close()
|
||||
if not rows:
|
||||
print("No shows registered.")
|
||||
db.close()
|
||||
return
|
||||
print(f"{'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}")
|
||||
print("-" * 80)
|
||||
for name, slug, feed_url, prot in rows:
|
||||
prot_str = "yes" if prot else "no"
|
||||
print(f"{name:<40} {slug:<30} {prot_str:<10}")
|
||||
|
||||
if detail:
|
||||
print("\nFeed URLs:")
|
||||
for name, slug, feed_url, prot in rows:
|
||||
print(f" {slug}: {feed_url}")
|
||||
|
||||
def add_show(feed_url):
|
||||
parsed = fetch_feed(feed_url)
|
||||
if parsed is None or not parsed.feed.get("title"):
|
||||
log_error(f"Could not determine show title from {feed_url}")
|
||||
return
|
||||
name = parsed.feed["title"]
|
||||
slug = slugify(name)
|
||||
db = open_subs_db()
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'manual', 0)",
|
||||
(slug, name, feed_url),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
log.info("Added show: %s (%s)", name, slug)
|
||||
fetch_show_episodes(slug, name, feed_url)
|
||||
|
||||
def remove_show_data(slug):
|
||||
pod_dir = PODCASTS_DIR / slug
|
||||
if pod_dir.exists():
|
||||
shutil.rmtree(pod_dir)
|
||||
pls = ROOT / "playlists" / f"{slug}.pls"
|
||||
if pls.exists():
|
||||
pls.unlink()
|
||||
|
||||
def delete_show(slug):
|
||||
db = open_subs_db()
|
||||
row = db.execute("SELECT name FROM shows WHERE slug = ?", (slug,)).fetchone()
|
||||
if row is None:
|
||||
log_error(f"No show found with slug '{slug}'.")
|
||||
return
|
||||
remove_show_data(slug)
|
||||
db.execute("DELETE FROM shows WHERE slug = ?", (slug,))
|
||||
db.commit()
|
||||
db.close()
|
||||
played_db = open_played_db()
|
||||
played_db.execute("DELETE FROM episodes WHERE show_slug = ?", (slug,))
|
||||
played_db.commit()
|
||||
played_db.close()
|
||||
log.info("Deleted show: %s (%s)", row["name"], slug)
|
||||
|
||||
def import_opml(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
except OSError as e:
|
||||
log_error(f"Cannot read OPML file: {e}")
|
||||
return
|
||||
shows = parse_opml(content)
|
||||
db = open_subs_db()
|
||||
for show in shows:
|
||||
slug = slugify(show["name"])
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'opml', 1)",
|
||||
(slug, show["name"], show["feed_url"]),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
log.info("OPML import: %d show(s) processed.", len(shows))
|
||||
|
||||
def run_fetch(config):
|
||||
g = config["gpodder"]
|
||||
if g.get("enable") is True:
|
||||
remote = gpodder_sync(config)
|
||||
if not remote:
|
||||
log.warning("No subscriptions retrieved from gPodder; using local registry only.")
|
||||
else:
|
||||
added = register_remote_shows(remote)
|
||||
pruned = prune_stale_shows(remote)
|
||||
log.info("Sync: %d added, %d pruned.", added, pruned)
|
||||
fetch_all_episodes()
|
||||
|
||||
def main():
|
||||
if "--delete-show" in sys.argv:
|
||||
idx = sys.argv.index("--delete-show")
|
||||
if idx + 1 < len(sys.argv):
|
||||
delete_show(sys.argv[idx + 1])
|
||||
else:
|
||||
print("Usage: fetch_podcasts.py --delete-show <slug>")
|
||||
sys.exit(1)
|
||||
return
|
||||
parser = argparse.ArgumentParser(description="Podcast fetcher for radio automation")
|
||||
parser.add_argument("--list-shows", action="store_true", help="List registered shows")
|
||||
parser.add_argument("--detail", action="store_true", help="With --list-shows, show feed URLs")
|
||||
parser.add_argument("--add-show", metavar="FEED_URL", help="Add a show from a feed URL")
|
||||
parser.add_argument("--delete-show", metavar="SLUG", help="Delete a show and its data")
|
||||
parser.add_argument("--import-opml", metavar="FILE", help="Import shows from an OPML file")
|
||||
args = parser.parse_args()
|
||||
|
||||
if "--add-show" in sys.argv:
|
||||
idx = sys.argv.index("--add-show")
|
||||
if idx + 1 < len(sys.argv):
|
||||
add_show(sys.argv[idx + 1])
|
||||
else:
|
||||
print("Usage: fetch_podcasts.py --add-show <feed-url>")
|
||||
sys.exit(1)
|
||||
return
|
||||
STATE_DIR.mkdir(exist_ok=True)
|
||||
LOGS_DIR.mkdir(exist_ok=True)
|
||||
PODCASTS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
if "--import-opml" in sys.argv:
|
||||
idx = sys.argv.index("--import-opml")
|
||||
if idx + 1 < len(sys.argv):
|
||||
import_opml_file(sys.argv[idx + 1])
|
||||
else:
|
||||
print("Usage: fetch_podcasts.py --import-opml <path-to-file.opml>")
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
if "--list-shows" in sys.argv:
|
||||
list_shows(detail="--detail" in sys.argv)
|
||||
return
|
||||
|
||||
# Normal run: optionally sync gpodder, then fetch all registered shows
|
||||
cfg = load_config()
|
||||
gp = cfg.get("gpodder", {})
|
||||
|
||||
if gp.get("enable", False):
|
||||
print("--- Syncing subscriptions from gpodder.net ---")
|
||||
try:
|
||||
sync_gpoddernet()
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"! gpodder sync failed (continuing with existing shows): {e}")
|
||||
|
||||
db = get_db()
|
||||
shows = db.execute("SELECT name, feed_url, slug FROM shows").fetchall()
|
||||
|
||||
if not shows:
|
||||
print("No shows registered. Import an OPML file, add a show, or enable gpodder sync.")
|
||||
db.close()
|
||||
return
|
||||
|
||||
print(f"--- Fetching episodes for {len(shows)} show(s) ---")
|
||||
for show_name, feed_url, slug in shows:
|
||||
try:
|
||||
fetch_feed(show_name, feed_url, slug, db)
|
||||
except Exception as e:
|
||||
print(f"[{show_name}] FAILED: {e}")
|
||||
|
||||
db.close()
|
||||
config = load_config()
|
||||
|
||||
if args.list_shows:
|
||||
list_shows(detail=args.detail)
|
||||
elif args.add_show:
|
||||
add_show(args.add_show)
|
||||
elif args.delete_show:
|
||||
delete_show(args.delete_show)
|
||||
elif args.import_opml:
|
||||
import_opml(args.import_opml)
|
||||
else:
|
||||
run_fetch(config)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -1,482 +1,446 @@
|
|||
#!/usr/bin/env jruby
|
||||
# frozen_string_literal: true
|
||||
#
|
||||
# fetch_podcasts.rb - Fetch podcast episodes from RSS feeds, manage subscriptions,
|
||||
# and download audio. JRuby-compatible (Ruby 3.1+ baseline).
|
||||
|
||||
require "nokogiri"
|
||||
require "net/http"
|
||||
require "uri"
|
||||
require "cgi"
|
||||
require "json"
|
||||
require "sqlite3"
|
||||
require "rexml/document"
|
||||
require "digest/md5"
|
||||
require "fileutils"
|
||||
require "time"
|
||||
require "optparse"
|
||||
|
||||
AUDIO_ROOT = Pathname.new(File.expand_path(__dir__))
|
||||
DB_PATH = AUDIO_ROOT.join("state/subscriptions.db")
|
||||
DOWNLOAD_DIR = AUDIO_ROOT.join("podcasts")
|
||||
CONFIG_PATH = AUDIO_ROOT.join("config.json")
|
||||
module RadioAutomation
|
||||
ROOT = File.expand_path("..", __dir__)
|
||||
CONFIG_PATH = File.join(ROOT, "config.json")
|
||||
STATE_DIR = File.join(ROOT, "state")
|
||||
SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
|
||||
PLAYED_DB = File.join(STATE_DIR, "played.db")
|
||||
PODCASTS_DIR = File.join(ROOT, "podcasts")
|
||||
LOGS_DIR = File.join(ROOT, "logs")
|
||||
AUDIO_EXTS = [".mp3", ".m4a"]
|
||||
|
||||
module Radio
|
||||
class Fetcher
|
||||
def initialize
|
||||
@db = get_db
|
||||
end
|
||||
def self.log_info(msg)
|
||||
puts "#{Time.now.iso8601} [INFO] #{msg}"
|
||||
append_log("fetch.log", msg)
|
||||
end
|
||||
|
||||
attr_reader :db
|
||||
def self.log_error(msg)
|
||||
puts "#{Time.now.iso8601} [ERROR] #{msg}"
|
||||
append_log("fetch.log", msg)
|
||||
end
|
||||
|
||||
def load_config
|
||||
JSON.parse(File.read(CONFIG_PATH))
|
||||
end
|
||||
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 get_db
|
||||
FileUtils.mkdir_p(DB_PATH.dirname)
|
||||
conn = SQLite3::Database.new(DB_PATH.to_s)
|
||||
conn.execute(<<~SQL)
|
||||
CREATE TABLE IF NOT EXISTS shows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
feed_url TEXT UNIQUE NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
opml_import INTEGER DEFAULT 0
|
||||
);
|
||||
SQL
|
||||
conn.execute(<<~SQL)
|
||||
CREATE TABLE IF NOT EXISTS seen (
|
||||
url TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
show_slug TEXT,
|
||||
duration_sec INTEGER DEFAULT 0,
|
||||
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
SQL
|
||||
# Migrate older databases lacking the new column
|
||||
cols = conn.execute("PRAGMA table_info(shows)").map { |r| r[1] }
|
||||
unless cols.include?("opml_import")
|
||||
conn.execute("ALTER TABLE shows ADD COLUMN opml_import INTEGER DEFAULT 0")
|
||||
end
|
||||
conn.commit rescue nil
|
||||
conn
|
||||
end
|
||||
def self.load_config
|
||||
JSON.parse(File.read(CONFIG_PATH))
|
||||
end
|
||||
|
||||
def sanitize_slug(name)
|
||||
slug = name.downcase.strip
|
||||
slug = slug.gsub(/[^a-z0-9\-_]/, "_")
|
||||
slug = slug.split("_").reject(&:empty?).join("_")
|
||||
slug[0, 80]
|
||||
end
|
||||
def self.slugify(name)
|
||||
s = name.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
|
||||
s[0, 60] || "show"
|
||||
end
|
||||
|
||||
# ---- Registration from OPML --------------------------------------------
|
||||
def register_shows_from_opml_xml(xml_bytes, opml_import: false)
|
||||
doc = Nokogiri::XML(xml_bytes)
|
||||
added = updated_flag = skipped = 0
|
||||
|
||||
doc.xpath("//outline").each do |node|
|
||||
xml_url = node["xmlUrl"].to_s
|
||||
next if xml_url.empty?
|
||||
otype = node["type"].to_s
|
||||
next unless otype.empty? || otype == "rss"
|
||||
|
||||
show_name = node["text"] || node["title"] || "Unknown Show"
|
||||
slug = sanitize_slug(show_name)
|
||||
flag = opml_import ? 1 : 0
|
||||
|
||||
existing = db.get_first_row("SELECT id, opml_import FROM shows WHERE feed_url=?", xml_url)
|
||||
if existing
|
||||
existing_id, existing_flag = existing
|
||||
if flag == 1 && existing_flag.to_i == 0
|
||||
db.execute("UPDATE shows SET opml_import=1 WHERE id=?", existing_id)
|
||||
updated_flag += 1
|
||||
puts " Protected existing: #{show_name} (#{slug})"
|
||||
else
|
||||
skipped += 1
|
||||
end
|
||||
else
|
||||
db.execute(
|
||||
"INSERT INTO shows (name, feed_url, slug, opml_import) VALUES (?, ?, ?, ?)",
|
||||
[show_name, xml_url, slug, flag]
|
||||
)
|
||||
added += 1
|
||||
puts " Registered: #{show_name} -> #{slug}"
|
||||
end
|
||||
end
|
||||
|
||||
db.commit
|
||||
puts " Added #{added} new show(s)." if added.positive?
|
||||
puts " Upgraded #{updated_flag} show(s) to protected." if updated_flag.positive?
|
||||
puts " Skipped #{skipped} duplicate(s)." if skipped.positive?
|
||||
added
|
||||
end
|
||||
|
||||
def import_opml_file(filepath)
|
||||
unless File.exist?(filepath)
|
||||
puts "ERROR: File not found: #{filepath}"
|
||||
exit 1
|
||||
end
|
||||
xml_data = File.binread(filepath)
|
||||
count = register_shows_from_opml_xml(xml_data, opml_import: true)
|
||||
total = db.get_first_row("SELECT COUNT(*) FROM shows")[0]
|
||||
puts "\nImport complete. #{count} new show(s) added. Total registered: #{total}"
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
# ---- gPodder.net sync ----------------------------------------------------
|
||||
def sync_gpoddernet
|
||||
cfg = load_config
|
||||
gp = cfg.fetch("gpodder", {})
|
||||
|
||||
unless gp.fetch("enable", false)
|
||||
puts "gpodder.net sync is disabled in config.json."
|
||||
return
|
||||
end
|
||||
|
||||
unless gp["username"] && gp["password"]
|
||||
puts "ERROR: gpodder.username/gpodder.password not set in config.json"
|
||||
exit 1
|
||||
end
|
||||
|
||||
url = "#{gp['host']}/subscriptions/#{gp['username']}.opml"
|
||||
puts "Fetching subscriptions from #{gp['host']} for '#{gp['username']}'..."
|
||||
|
||||
uri = URI(url)
|
||||
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
||||
req = Net::HTTP::Get.new(uri)
|
||||
req.basic_auth(gp["username"], gp["password"])
|
||||
http.request(req)
|
||||
end
|
||||
|
||||
if resp.code == "401"
|
||||
puts "ERROR: Authentication failed. Check username/password in config.json."
|
||||
exit 1
|
||||
elsif resp.code != "200"
|
||||
puts "ERROR: Unexpected response #{resp.code}: #{resp.body[0, 200]}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Collect all feed URLs from the current subscription list
|
||||
root_doc = Nokogiri::XML(resp.body)
|
||||
gp_feed_urls = Set.new
|
||||
root_doc.xpath("//outline").each do |node|
|
||||
fu = node["xmlUrl"].to_s
|
||||
gp_feed_urls.add(fu) unless fu.empty?
|
||||
end
|
||||
|
||||
count = register_shows_from_opml_xml(resp.body, opml_import: false)
|
||||
total = db.get_first_row("SELECT COUNT(*) FROM shows")[0]
|
||||
|
||||
prune_removed_shows(gp_feed_urls)
|
||||
|
||||
puts "\nSync complete. #{count} new, total registered: #{total}"
|
||||
end
|
||||
|
||||
def prune_removed_shows(gp_feed_urls)
|
||||
rows = db.execute("SELECT slug, feed_url, name FROM shows WHERE opml_import = 0")
|
||||
to_remove = rows.select { |_slug, feed_url, _name| !gp_feed_urls.include?(feed_url) }
|
||||
return if to_remove.empty?
|
||||
|
||||
to_remove.each do |slug, _feed_url, name|
|
||||
puts " Removing unsubscribed show: #{name} (#{slug})"
|
||||
db.execute("DELETE FROM seen WHERE show_slug=?", slug)
|
||||
db.execute("DELETE FROM shows WHERE slug=?", slug)
|
||||
show_dir = DOWNLOAD_DIR.join(slug)
|
||||
if Dir.exist?(show_dir)
|
||||
FileUtils.rm_rf(show_dir)
|
||||
puts " Deleted directory: #{show_dir}"
|
||||
end
|
||||
end
|
||||
|
||||
db.commit
|
||||
puts " Pruned #{to_remove.size} removed show(s)."
|
||||
end
|
||||
|
||||
# ---- Manual management ---------------------------------------------------
|
||||
def delete_show(slug)
|
||||
row = db.get_first_row("SELECT name FROM shows WHERE slug=?", slug)
|
||||
if row.nil?
|
||||
puts "No show found with slug '#{slug}'."
|
||||
return
|
||||
end
|
||||
name = row[0]
|
||||
puts "Deleting show: #{name} (#{slug})"
|
||||
db.execute("DELETE FROM seen WHERE show_slug=?", slug)
|
||||
db.execute("DELETE FROM shows WHERE slug=?", slug)
|
||||
db.commit
|
||||
|
||||
show_dir = DOWNLOAD_DIR.join(slug)
|
||||
if Dir.exist?(show_dir)
|
||||
FileUtils.rm_rf(show_dir)
|
||||
puts "Deleted directory: #{show_dir}"
|
||||
end
|
||||
|
||||
pls_file = AUDIO_ROOT.join("playlists", "#{slug}.pls")
|
||||
if File.exist?(pls_file)
|
||||
File.delete(pls_file)
|
||||
puts "Deleted playlist: #{pls_file}"
|
||||
end
|
||||
|
||||
puts "Done."
|
||||
end
|
||||
|
||||
def add_show(feed_url)
|
||||
puts "Fetching feed: #{feed_url}"
|
||||
parsed = parse_feed(feed_url)
|
||||
if parsed.nil?
|
||||
puts "ERROR: Invalid or unreachable feed."
|
||||
exit 1
|
||||
end
|
||||
|
||||
show_name, entries = parsed
|
||||
slug = sanitize_slug(show_name)
|
||||
puts " Title: #{show_name}"
|
||||
puts " Slug: #{slug}"
|
||||
puts " Entries found: #{entries.size}"
|
||||
|
||||
existing = db.get_first_row("SELECT name FROM shows WHERE feed_url=?", feed_url)
|
||||
if existing
|
||||
puts "NOTE: Feed already registered as '#{existing[0]}'. Nothing to do."
|
||||
return
|
||||
end
|
||||
|
||||
db.execute(
|
||||
"INSERT INTO shows (name, feed_url, slug, opml_import) VALUES (?, ?, ?, 1)",
|
||||
[show_name, feed_url, slug]
|
||||
# ---------------------------------------------------------
|
||||
# Database
|
||||
# ---------------------------------------------------------
|
||||
def self.open_subs_db
|
||||
db = SQLite3::Database.new(SUBS_DB)
|
||||
db.results_as_hash = true
|
||||
db.execute <<-SQL
|
||||
CREATE TABLE IF NOT EXISTS shows (
|
||||
slug TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
feed_url TEXT NOT NULL UNIQUE,
|
||||
source TEXT DEFAULT 'manual',
|
||||
opml_import INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
db.commit
|
||||
puts " Registered: #{show_name} -> #{slug}"
|
||||
SQL
|
||||
db
|
||||
end
|
||||
|
||||
puts "\n--- Fetching episodes ---"
|
||||
fetch_feed(show_name, feed_url, slug)
|
||||
def self.open_played_db
|
||||
db = SQLite3::Database.new(PLAYED_DB)
|
||||
db.results_as_hash = true
|
||||
db.execute <<-SQL
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
show_slug TEXT NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
title TEXT,
|
||||
file_path TEXT,
|
||||
duration_seconds INTEGER,
|
||||
played_at TEXT,
|
||||
UNIQUE(show_slug, guid)
|
||||
)
|
||||
SQL
|
||||
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
|
||||
|
||||
# ---- Core fetching ---------------------------------------------------------
|
||||
def parse_feed(feed_url)
|
||||
body = http_get(feed_url)
|
||||
return nil if body.nil?
|
||||
def self.parse_opml(xml_string)
|
||||
doc = REXML::Document.new(xml_string)
|
||||
shows = []
|
||||
REXML::XPath.each(doc, "//outline[@xmlUrl]") do |node|
|
||||
feed_url = node.attributes["xmlUrl"].to_s.strip
|
||||
name = node.attributes["text"].to_s.strip
|
||||
next unless feed_url =~ /\Ahttps?:\/\//
|
||||
shows << { "name" => name, "feed_url" => feed_url }
|
||||
end
|
||||
shows
|
||||
rescue REXML::ParseException => e
|
||||
log_error("Failed to parse OPML XML: #{e.message}")
|
||||
[]
|
||||
end
|
||||
|
||||
doc = Nokogiri::XML(body)
|
||||
doc.remove_namespaces!
|
||||
def self.register_remote_shows(remote_shows)
|
||||
db = open_subs_db
|
||||
added = 0
|
||||
remote_shows.each do |show|
|
||||
slug = slugify(show["name"])
|
||||
existing = db.get_first_value("SELECT slug FROM shows WHERE slug = ?", slug)
|
||||
if existing.nil?
|
||||
db.execute(
|
||||
"INSERT INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'gpodder', 0)",
|
||||
[slug, show["name"], show["feed_url"]]
|
||||
)
|
||||
log_info("Registered new show: #{show['name']} (#{slug})")
|
||||
added += 1
|
||||
end
|
||||
end
|
||||
db.close
|
||||
added
|
||||
end
|
||||
|
||||
# Support both RSS (<channel>) and Atom (<feed>)
|
||||
channel = doc.at_xpath("//channel") || doc.at_xpath("//feed")
|
||||
return nil if channel.nil?
|
||||
def self.prune_stale_shows(remote_shows)
|
||||
db = open_subs_db
|
||||
remote_slugs = remote_shows.map { |s| slugify(s["name"]) }
|
||||
stale = db.query_all("SELECT slug, name FROM shows WHERE source = 'gpodder' AND opml_import = 0")
|
||||
removed = 0
|
||||
stale.each do |row|
|
||||
next if remote_slugs.include?(row["slug"])
|
||||
remove_show_data(row["slug"])
|
||||
db.execute("DELETE FROM shows WHERE slug = ?", [row["slug"]])
|
||||
log_info("Pruned stale show: #{row['name']} (#{row['slug']})")
|
||||
removed += 1
|
||||
end
|
||||
db.close
|
||||
removed
|
||||
end
|
||||
|
||||
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 = []
|
||||
if doc.root.name == "rss"
|
||||
doc.xpath("//item").each do |item|
|
||||
enc = item.at_xpath(".//enclosure")
|
||||
next if enc.nil?
|
||||
entries << {
|
||||
url: enc["url"].to_s,
|
||||
type: enc["type"].to_s,
|
||||
length: enc["length"].to_s,
|
||||
title: (item.at_xpath("./title")&.text.presence || "untitled")
|
||||
}
|
||||
end
|
||||
else
|
||||
doc.xpath("//entry").each do |entry|
|
||||
link = entry.at_xpath("./link[@rel='enclosure']") ||
|
||||
entry.at_xpath("./link")
|
||||
next if link.nil?
|
||||
dur = entry.at_xpath(".//media:duration")
|
||||
entries << {
|
||||
url: link["href"].to_s,
|
||||
type: link["type"].to_s,
|
||||
length: dur ? dur.text : "",
|
||||
title: (entry.at_xpath("./title")&.text.presence || "untitled")
|
||||
}
|
||||
def self.fetch_feed(feed_url)
|
||||
uri = URI.parse(feed_url)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = (uri.scheme == "https")
|
||||
http.open_timeout = 30
|
||||
http.read_timeout = 60
|
||||
req = Net::HTTP::Get.new(uri)
|
||||
req["User-Agent"] = "radio-automation/1.0"
|
||||
resp = http.request(req)
|
||||
raise "Feed HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
|
||||
resp.body
|
||||
rescue StandardError => e
|
||||
log_error("Feed fetch error for #{feed_url}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def self.download_episode(url, dest_dir, filename)
|
||||
FileUtils.mkdir_p(dest_dir)
|
||||
dest = File.join(dest_dir, filename)
|
||||
return dest if File.exist?(dest)
|
||||
|
||||
uri = URI.parse(url)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = (uri.scheme == "https")
|
||||
http.open_timeout = 30
|
||||
http.read_timeout = 120
|
||||
req = Net::HTTP::Get.new(uri)
|
||||
req["User-Agent"] = "radio-automation/1.0"
|
||||
|
||||
tmp = "#{dest}.part"
|
||||
begin
|
||||
http.request(req) do |resp|
|
||||
raise "Download HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
|
||||
File.open(tmp, "wb") do |f|
|
||||
resp.read_body { |chunk| f.write(chunk) }
|
||||
end
|
||||
end
|
||||
|
||||
[title, entries]
|
||||
File.rename(tmp, dest)
|
||||
dest
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
args = ARGV.dup
|
||||
f = Radio::Fetcher.new
|
||||
def self.fetch_show_episodes(slug, name, feed_url)
|
||||
dest_dir = File.join(PODCASTS_DIR, slug)
|
||||
raw = fetch_feed(feed_url)
|
||||
return 0 if raw.nil?
|
||||
|
||||
if args.include?("--delete-show")
|
||||
idx = args.index("--delete-show")
|
||||
val = args[idx + 1]
|
||||
if val.nil?
|
||||
puts "Usage: fetch_podcasts.rb --delete-show <slug>"
|
||||
exit 1
|
||||
played_db = open_played_db
|
||||
seen = played_db.query("SELECT guid FROM episodes WHERE show_slug = ?", slug).map { |r| r["guid"] }
|
||||
subs_db = open_subs_db
|
||||
new_count = 0
|
||||
|
||||
# Simple regex-based RSS/Atom entry extraction
|
||||
raw.scan(/<(?:item|entry)[^>]*>(.*?)<\/(?:item|entry)>/mi).each do |(entry_xml)|
|
||||
guid_m = entry_xml.match(/<guid[^>]*>([^<]*)<\/guid>|<id>([^<]*)<\/id>|<link[^>]*href="([^"]+)"/i)
|
||||
guid = guid_m ? (guid_m[1] || guid_m[2] || guid_m[3]).strip : Digest::MD5.hexdigest(entry_xml[0, 200])
|
||||
next if seen.include?(guid)
|
||||
|
||||
enc_m = entry_xml.match(/enclosure[^>]*url="([^"]+)"/i)
|
||||
next unless enc_m
|
||||
audio_url = enc_m[1]
|
||||
|
||||
title_m = entry_xml.match(/<title[^>]*>([^<]*)<\/title>/i)
|
||||
title = title_m ? title_m[1].strip : "untitled"
|
||||
filename = safe_filename(title, guid[-20..])
|
||||
file_path = download_episode(audio_url, dest_dir, filename)
|
||||
next if file_path.nil?
|
||||
|
||||
duration = extract_duration(entry_xml)
|
||||
played_db.execute(
|
||||
"INSERT OR IGNORE INTO episodes (show_slug, guid, title, file_path, duration_seconds, played_at) VALUES (?, ?, ?, ?, ?, NULL)",
|
||||
[slug, guid, title, file_path, duration]
|
||||
)
|
||||
new_count += 1
|
||||
log_info(" New episode: #{title} [#{filename}]")
|
||||
end
|
||||
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)
|
||||
puts "--- Syncing subscriptions from gpodder.net ---"
|
||||
played_db.close
|
||||
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
|
||||
f.sync_gpoddernet
|
||||
rescue SystemExit
|
||||
raise
|
||||
total_new += fetch_show_episodes(show["slug"], show["name"], show["feed_url"])
|
||||
rescue StandardError => e
|
||||
puts "! gpodder sync failed (continuing with existing shows): #{e.message}"
|
||||
log_error("Unexpected error fetching #{show['slug']}: #{e.message}")
|
||||
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?
|
||||
puts "No shows registered. Import an OPML file, add a show, or enable gpodder sync."
|
||||
else
|
||||
puts "--- Fetching episodes for #{shows.size} show(s) ---"
|
||||
shows.each do |show_name, feed_url, slug|
|
||||
begin
|
||||
f.fetch_feed(show_name, feed_url, slug)
|
||||
rescue StandardError => e
|
||||
puts "[#{show_name}] FAILED: #{e.message}"
|
||||
end
|
||||
end
|
||||
# ---------------------------------------------------------
|
||||
# Admin operations
|
||||
# ---------------------------------------------------------
|
||||
def self.list_shows(detail: false)
|
||||
db = open_subs_db
|
||||
rows = db.query_all("SELECT slug, name, feed_url, source, opml_import FROM shows ORDER BY name")
|
||||
db.close
|
||||
if rows.empty?
|
||||
puts "No shows registered."
|
||||
return
|
||||
end
|
||||
puts format("%-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
|
||||
ensure
|
||||
f&.close
|
||||
end
|
||||
|
||||
main
|
||||
RadioAutomation.main
|
||||
|
|
|
|||
|
|
@ -1,580 +1,200 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# install_for_jruby - Setup for liquidsoap radio automation stack (JRuby edition)
|
||||
# Target: Linux Mint 22.3 (Ubuntu 24.04 Noble based)
|
||||
#
|
||||
# Usage: sudo ./install_for_jruby
|
||||
# Must be run from within the target directory (e.g., /srv/audio/)
|
||||
#
|
||||
# Detects existing Java/JRuby; installs OpenJDK 21 and JRuby 10.1.1.0 into /opt
|
||||
# only if missing, integrating JRuby via update-alternatives. Installs gems one
|
||||
# at a time with a raised JVM heap to avoid OOM on low-RAM hosts.
|
||||
#
|
||||
#!/usr/bin/env bash
|
||||
# install_for_jruby - Provision the JRuby-based radio automation stack.
|
||||
# Detects/reuses existing Java and JRuby; installs gems one at a time.
|
||||
# Must be run as root from within the target directory (e.g. /srv/radio/).
|
||||
# Idempotent: safe to re-run.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Determine install directory from script location
|
||||
# -------------------------------------------------------
|
||||
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
|
||||
INSTALL_DIR="$(dirname "$SCRIPT_PATH")"
|
||||
INSTALL_DIR="$(pwd)"
|
||||
SERVICE_NAME="$(basename "$INSTALL_DIR")"
|
||||
LIQUIDSOAP_USER="liquidsoap"
|
||||
|
||||
JRUBY_VERSION="10.1.1.0"
|
||||
JRUBY_HOME="/opt/jruby-${JRUBY_VERSION}"
|
||||
GEM_HOME_LOCAL="$INSTALL_DIR/.gems"
|
||||
JRBURY_VERSION="10.1.1.0"
|
||||
MAVEN_BASE="https://repo1.maven.org/maven2/org/jruby/jruby-dist"
|
||||
JRUBY_HOME_PINNED="/opt/jruby-${JRBURY_VERSION}"
|
||||
|
||||
echo "=== Radio Automation Installer (JRuby) ==="
|
||||
echo "Install directory: $INSTALL_DIR"
|
||||
echo "Service name: $SERVICE_NAME"
|
||||
echo ""
|
||||
echo "Install dir: $INSTALL_DIR"
|
||||
echo "Service name: $SERVICE_NAME"
|
||||
echo
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 0. Validate environment
|
||||
# -------------------------------------------------------
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "ERROR: This script must be run as root (sudo)."
|
||||
exit 1
|
||||
# --- Prompt for config values (pre-fill from existing config.json) ---
|
||||
EXISTING_CONFIG="$INSTALL_DIR/config.json"
|
||||
declare -A CFG
|
||||
CFG[ICE_HOST]="localhost"
|
||||
CFG[ICE_PORT]="7777"
|
||||
CFG[ICE_MOUNT]="/audio.mp3"
|
||||
CFG[ICE_USER]="source"
|
||||
CFG[ICE_PASS]=""
|
||||
CFG[GPODDER_ENABLE]="false"
|
||||
CFG[GPODDER_HOST]="https://gpodder.net"
|
||||
CFG[GPODDER_USER]=""
|
||||
CFG[GPODDER_PASS]=""
|
||||
|
||||
if [[ -f "$EXISTING_CONFIG" ]]; then
|
||||
echo "Existing config.json found; using as defaults."
|
||||
CFG[ICE_HOST]=$(jq -r '.icecast.host // "localhost"' "$EXISTING_CONFIG")
|
||||
CFG[ICE_PORT]=$(jq -r '.icecast.port // 7777' "$EXISTING_CONFIG")
|
||||
CFG[ICE_MOUNT]=$(jq -r '.icecast.mount // "/audio.mp3"' "$EXISTING_CONFIG")
|
||||
CFG[ICE_USER]=$(jq -r '.icecast.username // "source"' "$EXISTING_CONFIG")
|
||||
CFG[ICE_PASS]=$(jq -r '.icecast.password // ""' "$EXISTING_CONFIG")
|
||||
CFG[GPODDER_ENABLE]=$(jq -r '.gpodder.enable // false' "$EXISTING_CONFIG")
|
||||
CFG[GPODDER_HOST]=$(jq -r '.gpodder.host // "https://gpodder.net"' "$EXISTING_CONFIG")
|
||||
CFG[GPODDER_USER]=$(jq -r '.gpodder.username // ""' "$EXISTING_CONFIG")
|
||||
CFG[GPODDER_PASS]=$(jq -r '.gpodder.password // ""' "$EXISTING_CONFIG")
|
||||
fi
|
||||
|
||||
if [ ! -d "$INSTALL_DIR" ]; then
|
||||
echo "ERROR: Install directory $INSTALL_DIR does not exist."
|
||||
exit 1
|
||||
fi
|
||||
read -rp "Icecast host [$CFG[ICE_HOST]]: " v; CFG[ICE_HOST]=${v:-$CFG[ICE_HOST]}
|
||||
read -rp "Icecast port [$CFG[ICE_PORT]]: " v; CFG[ICE_PORT]=${v:-$CFG[ICE_PORT]}
|
||||
read -rp "Icecast mount [$CFG[ICE_MOUNT]]: " v; CFG[ICE_MOUNT]=${v:-$CFG[ICE_MOUNT]}
|
||||
read -rp "Icecast source username [$CFG[ICE_USER]]: " v; CFG[ICE_USER]=${v:-$CFG[ICE_USER]}
|
||||
read -rsp "Icecast source password [$CFG[ICE_PASS]]: "; echo; CFG[ICE_PASS]=${v:-$CFG[ICE_PASS]}
|
||||
read -rp "Enable gPodder sync? (true/false) [$CFG[GPODDER_ENABLE]]: " v; CFG[GPODDER_ENABLE]=${v:-$CFG[GPODDER_ENABLE]}
|
||||
read -rp "gPodder host [$CFG[GPODDER_HOST]]: " v; CFG[GPODDER_HOST]=${v:-$CFG[GPODDER_HOST]}
|
||||
read -rp "gPodder username [$CFG[GPODDER_USER]]: " v; CFG[GPODDER_USER]=${v:-$CFG[GPODDER_USER]}
|
||||
read -rsp "gPodder password [$CFG[GPODDER_PASS]]: "; echo; CFG[GPODDER_PASS]=${v:-$CFG[GPODDER_PASS]}
|
||||
|
||||
if [ ! -w "$INSTALL_DIR" ]; then
|
||||
echo "ERROR: Cannot write to $INSTALL_DIR. Check permissions."
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
echo "Summary:"
|
||||
echo " Icecast: ${CFG[ICE_HOST]}:${CFG[ICE_PORT]}${CFG[ICE_MOUNT]}"
|
||||
echo " gPodder sync: ${CFG[GPODDER_ENABLE]}"
|
||||
read -rp "Proceed? [y/N] " confirm
|
||||
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
||||
|
||||
REQUIRED_DIRS=(
|
||||
"music"
|
||||
"podcasts"
|
||||
"jingles"
|
||||
"announcements"
|
||||
"playlists"
|
||||
"state"
|
||||
"logs"
|
||||
)
|
||||
# --- Base packages ---
|
||||
echo "Installing base packages..."
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq liquidsoap icecast2 jq curl unzip ca-certificates >/dev/null
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Helper: prompt with default value
|
||||
# -------------------------------------------------------
|
||||
prompt() {
|
||||
local var_name="$1"
|
||||
local prompt_text="$2"
|
||||
local default_value="${3:-}"
|
||||
local hide_input="${4:-false}"
|
||||
|
||||
if [ -n "$default_value" ]; then
|
||||
read -rp "$prompt_text [$default_value]: " input
|
||||
if [ -z "$input" ]; then
|
||||
eval "$var_name=$default_value"
|
||||
else
|
||||
eval "$var_name=\$input"
|
||||
fi
|
||||
else
|
||||
if [ "$hide_input" = "true" ]; then
|
||||
read -rsp "$prompt_text: " input
|
||||
echo ""
|
||||
eval "$var_name=\$input"
|
||||
else
|
||||
read -rp "$prompt_text: " input
|
||||
eval "$var_name=\$input"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
prompt_confirm() {
|
||||
local var_name="$1"
|
||||
local prompt_text="$2"
|
||||
local default_value="${3:-no}"
|
||||
read -rp "$prompt_text [$default_value]: " input
|
||||
if [ -z "$input" ]; then
|
||||
input="$default_value"
|
||||
fi
|
||||
case "$input" in
|
||||
[Yy]* ) eval "$var_name=true" ;;
|
||||
* ) eval "$var_name=false" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 1. Interactive configuration
|
||||
# -------------------------------------------------------
|
||||
CONFIG_FILE="$INSTALL_DIR/config.json"
|
||||
|
||||
EXISTING_CONFIG=false
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
EXISTING_CONFIG=true
|
||||
echo "--- Loading existing config.json for defaults ---"
|
||||
ICECAST_HOST_DEFAULT=$(jq -r '.icecast.host // "localhost"' "$CONFIG_FILE")
|
||||
ICECAST_PORT_DEFAULT=$(jq -r '.icecast.port // 7777' "$CONFIG_FILE")
|
||||
ICECAST_MOUNT_DEFAULT=$(jq -r '.icecast.mount // "/audio.mp3"' "$CONFIG_FILE")
|
||||
ICECAST_USERNAME_DEFAULT=$(jq -r '.icecast.username // "source"' "$CONFIG_FILE")
|
||||
GPODDER_ENABLE_DEFAULT=$(jq -r 'if .gpodder.enable == true then "yes" else "no" end' "$CONFIG_FILE")
|
||||
GPODDER_HOST_DEFAULT=$(jq -r '.gpodder.host // "https://gpodder.net"' "$CONFIG_FILE")
|
||||
GPODDER_USERNAME_DEFAULT=$(jq -r '.gpodder.username // ""' "$CONFIG_FILE")
|
||||
else
|
||||
ICECAST_HOST_DEFAULT="localhost"
|
||||
ICECAST_PORT_DEFAULT="7777"
|
||||
ICECAST_MOUNT_DEFAULT="/audio.mp3"
|
||||
ICECAST_USERNAME_DEFAULT="source"
|
||||
GPODDER_ENABLE_DEFAULT="no"
|
||||
GPODDER_HOST_DEFAULT="https://gpodder.net"
|
||||
GPODDER_USERNAME_DEFAULT=""
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- Icecast Settings ---"
|
||||
echo ""
|
||||
|
||||
ICECAST_HOST=""
|
||||
ICECAST_PORT=""
|
||||
ICECAST_MOUNT=""
|
||||
ICECAST_USERNAME=""
|
||||
ICECAST_PASSWORD=""
|
||||
|
||||
while true; do
|
||||
prompt ICECAST_HOST "Icecast host" "$ICECAST_HOST_DEFAULT"
|
||||
[[ "$ICECAST_HOST" =~ ^[a-zA-Z0-9._-]+$ ]] && break
|
||||
echo "Invalid hostname. Try again."
|
||||
done
|
||||
|
||||
while true; do
|
||||
prompt ICECAST_PORT "Source port" "$ICECAST_PORT_DEFAULT"
|
||||
[[ "$ICECAST_PORT" =~ ^[0-9]{1,5}$ ]] && (( ICECAST_PORT >= 1 )) && (( ICECAST_PORT <= 65535 )) && break
|
||||
echo "Port must be a number between 1 and 65535. Try again."
|
||||
done
|
||||
|
||||
while true; do
|
||||
prompt ICECAST_MOUNT "Mount point" "$ICECAST_MOUNT_DEFAULT"
|
||||
[[ "$ICECAST_MOUNT" == /* ]] && break
|
||||
echo "Mount point must start with /. Try again."
|
||||
done
|
||||
|
||||
prompt ICECAST_USERNAME "Source username" "$ICECAST_USERNAME_DEFAULT"
|
||||
|
||||
while true; do
|
||||
prompt ICECAST_PASSWORD "Source password" "" "true"
|
||||
[ -n "$ICECAST_PASSWORD" ] && break
|
||||
echo "Password cannot be empty. Try again."
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "--- gPodder.net Sync Settings ---"
|
||||
echo ""
|
||||
|
||||
GPODDER_ENABLE=false
|
||||
if [ "$GPODDER_ENABLE_DEFAULT" = "yes" ]; then
|
||||
prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "yes"
|
||||
else
|
||||
prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "no"
|
||||
fi
|
||||
|
||||
GPODDER_HOST=""
|
||||
GPODDER_USERNAME=""
|
||||
GPODDER_PASSWORD=""
|
||||
|
||||
if [ "$GPODDER_ENABLE" = "true" ]; then
|
||||
prompt GPODDER_HOST "gPodder host" "$GPODDER_HOST_DEFAULT"
|
||||
while true; do
|
||||
prompt GPODDER_USERNAME "gPodder username/email" "$GPODDER_USERNAME_DEFAULT"
|
||||
[ -n "$GPODDER_USERNAME" ] && break
|
||||
echo "Username cannot be empty. Try again."
|
||||
done
|
||||
while true; do
|
||||
prompt GPODDER_PASSWORD "gPodder password" "" "true"
|
||||
[ -n "$GPODDER_PASSWORD" ] && break
|
||||
echo "Password cannot be empty. Try again."
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- Summary ---"
|
||||
echo " Icecast: ${ICECAST_HOST}:${ICECAST_PORT}${ICECAST_MOUNT} (user: ${ICECAST_USERNAME})"
|
||||
echo " gPodder: $( [ "$GPODDER_ENABLE" = "true" ] && echo "enabled (${GPODDER_USERNAME}@${GPODDER_HOST})" || echo "disabled" )"
|
||||
echo ""
|
||||
|
||||
CONFIRM_INSTALL=false
|
||||
prompt_confirm CONFIRM_INSTALL "Proceed with these settings?" "yes"
|
||||
if [ "$CONFIRM_INSTALL" != "true" ]; then
|
||||
echo "Aborted by user."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 2. Base system packages (always needed regardless of Java state)
|
||||
# -------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- Installing base system packages ---"
|
||||
apt-get update
|
||||
apt-get install -y \
|
||||
liquidsoap \
|
||||
icecast2 \
|
||||
jq \
|
||||
curl \
|
||||
ca-certificates \
|
||||
unzip
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 3. Detect / install Java (OpenJDK 21 headless)
|
||||
# -------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- Checking Java ---"
|
||||
# --- Detect or install Java (>= 21) ---
|
||||
JAVA_OK=false
|
||||
if command -v java >/dev/null 2>&1; then
|
||||
JAVA_VER_LINE=$(java -version 2>&1 | head -1)
|
||||
# Extract major version: handles both '"21.0.x"' and '1.8.0_xxx' styles
|
||||
JAVA_MAJOR=$(echo "$JAVA_VER_LINE" | sed -nE 's/.*"([0-9]+)(\.[0-9]+)?".*/\1/p')
|
||||
if [ -n "$JAVA_MAJOR" ] && [ "$JAVA_MAJOR" -ge 21 ]; then
|
||||
JAVA_OK=true
|
||||
echo " Found suitable Java ($JAVA_VER_LINE)"
|
||||
else
|
||||
echo " Java found but version too old ($JAVA_VER_LINE); installing OpenJDK 21."
|
||||
fi
|
||||
if command -v java &>/dev/null; then
|
||||
JAVA_VER=$(java -version 2>&1 | head -1 | sed 's/.*"\([0-9]*\)\..*/\1/')
|
||||
if (( JAVA_VER >= 21 )); then
|
||||
echo "Found Java $JAVA_VER; reusing."
|
||||
JAVA_OK=true
|
||||
fi
|
||||
fi
|
||||
if [[ "$JAVA_OK" != "true" ]]; then
|
||||
echo "Installing OpenJDK 21 headless..."
|
||||
apt-get install -y -qq openjdk-21-jdk-headless >/dev/null
|
||||
fi
|
||||
|
||||
# --- Detect or install JRuby ---
|
||||
JRUBY_BIN=""
|
||||
if [[ -x "$JRUBY_HOME_PINNED/bin/jruby" ]]; then
|
||||
JRUBY_BIN="$JRUBY_HOME_PINNED/bin/jruby"
|
||||
echo "Found pinned JRuby at $JRUBY_HOME_PINNED"
|
||||
elif command -v jruby &>/dev/null; then
|
||||
JRUBY_BIN="$(command -v jruby)"
|
||||
echo "Found JRuby on PATH: $JRUBY_BIN"
|
||||
else
|
||||
echo " No Java detected; installing OpenJDK 21."
|
||||
echo "Downloading JRuby ${JRBURY_VERSION} from Maven Central..."
|
||||
TARBALL="jruby-dist-${JRBURY_VERSION}-bin.tar.gz"
|
||||
URL="${MAVEN_BASE}/${JRBURY_VERSION}/${TARBALL}"
|
||||
cd /tmp
|
||||
curl -fsSL -o "$TARBALL" "$URL"
|
||||
tar xzf "$TARBALL"
|
||||
mv "jruby-${JRBURY_VERSION}" "$JRUBY_HOME_PINNED"
|
||||
rm -f "$TARBALL"
|
||||
JRUBY_BIN="$JRUBY_HOME_PINNED/bin/jruby"
|
||||
echo "Installed JRuby to $JRUBY_HOME_PINNED"
|
||||
fi
|
||||
|
||||
if [ "$JAVA_OK" != "true" ]; then
|
||||
apt-get install -y openjdk-21-jre-headless
|
||||
echo " Installed OpenJDK 21."
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 4. Detect / install JRuby
|
||||
# -------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- Checking JRuby ---"
|
||||
JRUBY_FOUND=""
|
||||
|
||||
# Preferred: our pinned home already present
|
||||
if [ -x "$JRUBY_HOME/bin/jruby" ]; then
|
||||
JRUBY_FOUND="$JRUBY_HOME/bin/jruby"
|
||||
echo " Found JRuby at $JRUBY_HOME"
|
||||
fi
|
||||
|
||||
# Fallback: any jruby resolvable on PATH (system, other /opt version, etc.)
|
||||
if [ -z "$JRUBY_FOUND" ] && command -v jruby >/dev/null 2>&1; then
|
||||
DETECTED_JRUBY="$(command -v jruby)"
|
||||
DETECTED_JRUBY="$(readlink -f "$DETECTED_JRUBY")"
|
||||
JRUBY_FOUND="$DETECTED_JRUBY"
|
||||
echo " Found existing JRuby on PATH: $DETECTED_JRUBY"
|
||||
echo " ($( "$DETECTED_JRUBY" -v 2>/dev/null | head -1 ))"
|
||||
fi
|
||||
|
||||
if [ -z "$JRUBY_FOUND" ]; then
|
||||
echo " No JRuby detected; installing JRuby ${JRUBY_VERSION} to /opt ..."
|
||||
JRUBY_TARBALL="/tmp/jruby-dist-${JRUBY_VERSION}-bin.tar.gz"
|
||||
# Post-9.1.14.0 releases are distributed via Maven Central as jruby-dist-*
|
||||
curl -fsSL "https://repo1.maven.org/maven2/org/jruby/jruby-dist/${JRUBY_VERSION}/jruby-dist-${JRUBY_VERSION}-bin.tar.gz" -o "$JRUBY_TARBALL"
|
||||
mkdir -p /opt
|
||||
tar -xzf "$JRUBY_TARBALL" -C /opt
|
||||
rm -f "$JRUBY_TARBALL"
|
||||
ln -sfn "$JRUBY_HOME" /opt/jruby-current
|
||||
JRUBY_FOUND="$JRUBY_HOME/bin/jruby"
|
||||
echo " Installed JRuby to $JRUBY_HOME"
|
||||
fi
|
||||
|
||||
# Derive the effective JRuby home from whichever binary we ended up using
|
||||
EFF_JRUBY_BIN="$JRUBY_FOUND"
|
||||
EFF_JRUBY_HOME="$(dirname "$(dirname "$EFF_JRUBY_BIN")")"
|
||||
export PATH="$EFF_JRUBY_HOME/bin:$PATH"
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 5. Integrate JRuby with the system via update-alternatives
|
||||
# -------------------------------------------------------
|
||||
echo "--- Integrating JRuby via update-alternatives ---"
|
||||
update-alternatives --install /usr/local/bin/jruby jruby "$EFF_JRUBY_BIN" 100
|
||||
# Also expose bundle/gem/rake under alternatives so they resolve system-wide
|
||||
for tool in bundle gem rake irb; do
|
||||
if [ -x "$EFF_JRUBY_HOME/bin/$tool" ]; then
|
||||
update-alternatives --install "/usr/local/bin/$tool" "$tool" "$EFF_JRUBY_HOME/bin/$tool" 100
|
||||
fi
|
||||
# --- Register via update-alternatives ---
|
||||
for cmd in jruby bundle gem rake irb; do
|
||||
ALT_SRC="$($JRUBY_BIN -e "puts Gem.bindir" 2>/dev/null || echo "$(${JRUBY_BIN%/jruby}${cmd}" --version &>/dev/null && which ${cmd} 2>/dev/null || echo "$JRUBY_HOME_PINNED/bin/${cmd}")")"
|
||||
# Simpler: just link the known bin paths
|
||||
LINK_TARGET="$JRUBY_HOME_PINNED/bin/${cmd}"
|
||||
if [[ -x "$LINK_TARGET" ]]; then
|
||||
ln -sf "$LINK_TARGET" "/usr/local/bin/${cmd}"
|
||||
update-alternatives --install "/usr/local/bin/${cmd}" "${cmd}" "$LINK_TARGET" 100 || true
|
||||
fi
|
||||
done
|
||||
echo " Registered jruby -> $EFF_JRUBY_BIN (priority 100)"
|
||||
echo "JRuby integrated via /usr/local/bin symlinks."
|
||||
|
||||
# Sanity-check that the integrated binary actually runs
|
||||
if ! "$EFF_JRUBY_BIN" -v >/dev/null 2>&1; then
|
||||
echo "ERROR: Integrated JRuby binary failed to execute. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
echo " Verified: $("${EFF_JRUBY_BIN}" -v 2>/dev/null | head -1)"
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 6. Install gems into a project-local gem home (one at a time)
|
||||
# -------------------------------------------------------
|
||||
echo "--- Installing Ruby gems ---"
|
||||
# --- Install gems one at a time (avoid OOM on low-RAM hosts) ---
|
||||
export GEM_HOME="$GEM_HOME_LOCAL"
|
||||
export GEM_PATH="$GEM_HOME_LOCAL"
|
||||
|
||||
# Raise the JVM heap for gem operations. JRuby defaults to ~500MB, which is
|
||||
# too small when bundler resolves multiple gems at once on a low-RAM host.
|
||||
export JRUBY_OPTS="-J-Xmx1g -J-Xss512k"
|
||||
|
||||
install_gem() {
|
||||
local name="$1"
|
||||
echo " Installing gem: $name"
|
||||
if ! "$EFF_JRUBY_BIN" -S gem install "$name" --no-document; then
|
||||
echo "ERROR: Failed to install gem '$name'."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
echo "Installing bundler..."
|
||||
"$JRUBY_BIN" -S gem install bundler --no-document 2>&1 | tail -1
|
||||
echo "Installing nokogiri..."
|
||||
"$JRUBY_BIN" -S gem install nokogiri --no-document 2>&1 | tail -1
|
||||
echo "Installing sqlite3..."
|
||||
"$JRUBY_BIN" -S gem install sqlite3 --no-document 2>&1 | tail -1
|
||||
echo "Installing json..."
|
||||
"$JRUBY_BIN" -S gem install json --no-document 2>&1 | tail -1
|
||||
|
||||
# Install bundler first (needed to drive bundle install), then each dependency
|
||||
# separately so a single OOM can't take down the whole set.
|
||||
install_gem bundler
|
||||
install_gem nokogiri
|
||||
install_gem sqlite3
|
||||
install_gem json
|
||||
|
||||
# Lock versions against the Gemfile now that every gem is present.
|
||||
(cd "$INSTALL_DIR" && \
|
||||
GEM_HOME="$GEM_HOME_LOCAL" GEM_PATH="$GEM_HOME_LOCAL" \
|
||||
JRUBY_OPTS="$JRUBY_OPTS" \
|
||||
"$EFF_JRUBY_BIN" -S bundle install --quiet)
|
||||
|
||||
echo " Gems installed to $GEM_HOME_LOCAL"
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 7. Create and verify directory structure
|
||||
# -------------------------------------------------------
|
||||
echo "--- Creating directory structure ---"
|
||||
MISSING=()
|
||||
for dir in "${REQUIRED_DIRS[@]}"; do
|
||||
full_path="$INSTALL_DIR/$dir"
|
||||
if [ ! -d "$full_path" ]; then
|
||||
mkdir -p "$full_path"
|
||||
MISSING+=("$dir")
|
||||
fi
|
||||
# --- Create directory structure ---
|
||||
for d in music podcasts jingles announcements playlists state logs; do
|
||||
mkdir -p "$INSTALL_DIR/$d"
|
||||
done
|
||||
|
||||
if [ ${#MISSING[@]} -gt 0 ]; then
|
||||
echo " Created: ${MISSING[*]}"
|
||||
else
|
||||
echo " All directories already present."
|
||||
fi
|
||||
|
||||
FAILED=()
|
||||
for dir in "${REQUIRED_DIRS[@]}"; do
|
||||
if [ ! -d "$INSTALL_DIR/$dir" ]; then
|
||||
FAILED+=("$dir")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
echo "ERROR: Failed to create directories: ${FAILED[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 8. Generate config.json from collected settings
|
||||
# -------------------------------------------------------
|
||||
echo "--- Generating config.json ---"
|
||||
|
||||
cat > "$CONFIG_FILE" << EOF
|
||||
# --- Generate config.json ---
|
||||
cat > "$INSTALL_DIR/config.json" <<EOF
|
||||
{
|
||||
"icecast": {
|
||||
"host": "${ICECAST_HOST}",
|
||||
"port": ${ICECAST_PORT},
|
||||
"mount": "${ICECAST_MOUNT}",
|
||||
"username": "${ICECAST_USERNAME}",
|
||||
"password": "${ICECAST_PASSWORD}"
|
||||
"host": "${CFG[ICE_HOST]}",
|
||||
"port": ${CFG[ICE_PORT]},
|
||||
"mount": "${CFG[ICE_MOUNT]}",
|
||||
"username": "${CFG[ICE_USER]}",
|
||||
"password": "${CFG[ICE_PASS]}"
|
||||
},
|
||||
"gpodder": {
|
||||
"enable": ${GPODDER_ENABLE},
|
||||
"host": "${GPODDER_HOST}",
|
||||
"username": "${GPODDER_USERNAME}",
|
||||
"password": "${GPODDER_PASSWORD}"
|
||||
"enable": ${CFG[GPODDER_ENABLE]},
|
||||
"host": "${CFG[GPODDER_HOST]}",
|
||||
"username": "${CFG[GPODDER_USER]}",
|
||||
"password": "${CFG[GPODDER_PASS]}"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
chmod 600 "$INSTALL_DIR/config.json"
|
||||
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
echo " Written: $CONFIG_FILE"
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 9. Create schedule.txt template (if not present)
|
||||
# -------------------------------------------------------
|
||||
SCHEDULE_FILE="$INSTALL_DIR/schedule.txt"
|
||||
if [ ! -f "$SCHEDULE_FILE" ]; then
|
||||
echo "--- Creating schedule.txt template ---"
|
||||
cat > "$SCHEDULE_FILE" << 'EOF'
|
||||
# Radio Schedule
|
||||
# Format: min hour dom mon dow TYPE TARGET [RUNLENGTH_SECONDS]
|
||||
# TYPE: show | stream
|
||||
# RUNLENGTH required for stream entries, ignored for show entries
|
||||
#
|
||||
# Examples:
|
||||
# 0 8 * * 2 show hardcore_history
|
||||
# 0 6 * * 1 stream http://example.org:8000/live.mp3 3600
|
||||
EOF
|
||||
else
|
||||
echo "--- schedule.txt already exists, skipping ---"
|
||||
# --- Ensure liquidsoap user exists ---
|
||||
if ! id liquidsoap &>/dev/null; then
|
||||
useradd --system --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin liquidsoap
|
||||
fi
|
||||
chown -R liquidsoap:liquidsoap "$INSTALL_DIR"
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 10. Verify required project files exist
|
||||
# -------------------------------------------------------
|
||||
echo "--- Verifying project files ---"
|
||||
PROJECT_FILES=("station.liq" "fetch_podcasts.rb" "update_playlists.rb" "Gemfile")
|
||||
FILE_ERRORS=()
|
||||
for f in "${PROJECT_FILES[@]}"; do
|
||||
if [ ! -f "$INSTALL_DIR/$f" ]; then
|
||||
FILE_ERRORS+=("$f")
|
||||
else
|
||||
echo " Found: $f"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#FILE_ERRORS[@]} -gt 0 ]; then
|
||||
echo "WARNING: Missing expected files: ${FILE_ERRORS[*]}"
|
||||
echo " The service will not start until these are in place."
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 11. Create systemd service named after the directory
|
||||
# -------------------------------------------------------
|
||||
echo "--- Creating systemd service: ${SERVICE_NAME}.service ---"
|
||||
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF
|
||||
# --- Write systemd service ---
|
||||
cat > /etc/systemd/system/"$SERVICE_NAME".service <<EOF
|
||||
[Unit]
|
||||
Description=Liquidsoap radio automation (${SERVICE_NAME})
|
||||
Description=Liquidsoap radio station ($SERVICE_NAME)
|
||||
After=network.target icecast2.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${LIQUIDSOAP_USER}
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
User=liquidsoap
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
Environment=GEM_HOME=${GEM_HOME_LOCAL}
|
||||
Environment=GEM_PATH=${GEM_HOME_LOCAL}
|
||||
ExecStart=/usr/bin/liquidsoap ${INSTALL_DIR}/station.liq
|
||||
Environment=JRUBY_OPTS=-J-Xmx1g -J-Xss512k
|
||||
ExecStart=/usr/bin/liquidsoap $INSTALL_DIR/station.liq
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=append:${INSTALL_DIR}/logs/liquidsoap.log
|
||||
StandardError=append:${INSTALL_DIR}/logs/liquidsoap.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 12. Create cron jobs (hourly fetch + hourly playlist update)
|
||||
# -------------------------------------------------------
|
||||
echo "--- Setting up cron jobs ---"
|
||||
# --- Set up cron jobs (deduplicated) ---
|
||||
FETCH_CMD="sudo -u liquidsoap env GEM_HOME=${GEM_HOME_LOCAL} GEM_PATH=${GEM_HOME_LOCAL} JRUBY_OPTS=-J-Xmx1g -J-Xss512k /usr/local/bin/jruby -S bundle exec $INSTALL_DIR/fetch_podcasts.rb >> $INSTALL_DIR/logs/cron_fetch.log 2>&1"
|
||||
UPDATE_CMD="sudo -u liquidsoap env GEM_HOME=${GEM_HOME_LOCAL} GEM_PATH=${GEM_HOME_LOCAL} JRUBY_OPTS=-J-Xmx1g -J-Xss512k /usr/local/bin/jruby -S bundle exec $INSTALL_DIR/update_playlists.rb >> $INSTALL_DIR/logs/cron_update.log 2>&1"
|
||||
|
||||
# Cron runs with a minimal PATH, so reference the absolute integrated path.
|
||||
RUBY_RUN="cd $INSTALL_DIR && GEM_HOME=$GEM_HOME_LOCAL GEM_PATH=$GEM_HOME_LOCAL /usr/local/bin/jruby -S bundle exec"
|
||||
FETCH_CRON="0 * * * * $RUBY_RUN fetch_podcasts.rb >> $INSTALL_DIR/logs/fetch.log 2>&1"
|
||||
UPDATE_CRON="30 * * * * $RUBY_RUN update_playlists.rb >> $INSTALL_DIR/logs/update.log 2>&1"
|
||||
|
||||
EXISTING_CRON=$(crontab -l -u "$LIQUIDSOAP_USER" 2>/dev/null || true)
|
||||
CLEANED_CRON=$(echo "$EXISTING_CRON" | grep -v "fetch_podcasts.rb\|update_playlists.rb" || true)
|
||||
|
||||
if ! echo "$EXISTING_CRON" | grep -q "fetch_podcasts.rb"; then
|
||||
CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON
|
||||
}$FETCH_CRON"
|
||||
echo " Added: fetch_podcasts.rb (hourly)"
|
||||
else
|
||||
echo " Skipped: fetch_podcasts.rb job already exists"
|
||||
fi
|
||||
|
||||
if ! echo "$EXISTING_CRON" | grep -q "update_playlists.rb"; then
|
||||
CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON
|
||||
}$UPDATE_CRON"
|
||||
echo " Added: update_playlists.rb (hourly at :30)"
|
||||
else
|
||||
echo " Skipped: update_playlists.rb job already exists"
|
||||
fi
|
||||
|
||||
echo "$CLEANED_CRON" | crontab -u "$LIQUIDSOAP_USER" -
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 13. Permissions
|
||||
# -------------------------------------------------------
|
||||
echo "--- Setting ownership and permissions ---"
|
||||
id -u "$LIQUIDSOAP_USER" &>/dev/null || useradd --system --shell /usr/sbin/nologin "$LIQUIDSOAP_USER"
|
||||
chown -R "${LIQUIDSOAP_USER}:${LIQUIDSOAP_USER}" "$INSTALL_DIR"
|
||||
chmod 700 "$INSTALL_DIR/state"
|
||||
chmod 700 "$INSTALL_DIR/logs"
|
||||
(crontab -l 2>/dev/null | grep -v "fetch_podcasts.rb"; echo "0 * * * * $FETCH_CMD") | crontab -
|
||||
(crontab -l 2>/dev/null | grep -v "update_playlists.rb"; echo "30 * * * * $UPDATE_CMD") | crontab -
|
||||
|
||||
# --- Reload and enable ---
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 14. Final verification
|
||||
# -------------------------------------------------------
|
||||
echo ""
|
||||
echo "=== Final Verification ==="
|
||||
ALL_OK=true
|
||||
for dir in "${REQUIRED_DIRS[@]}"; do
|
||||
if [ -d "$INSTALL_DIR/$dir" ] && [ -r "$INSTALL_DIR/$dir" ] && [ -w "$INSTALL_DIR/$dir" ]; then
|
||||
printf " [OK] %-20s\n" "$dir/"
|
||||
else
|
||||
printf " [FAIL] %-20s (missing or inaccessible)\n" "$dir/"
|
||||
ALL_OK=false
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then
|
||||
echo " [OK] Service file: ${SERVICE_NAME}.service"
|
||||
else
|
||||
echo " [FAIL] Service file not created"
|
||||
ALL_OK=false
|
||||
fi
|
||||
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
if jq empty "$CONFIG_FILE" 2>/dev/null; then
|
||||
echo " [OK] Config: config.json (valid JSON)"
|
||||
else
|
||||
echo " [FAIL] Config: config.json (invalid JSON)"
|
||||
ALL_OK=false
|
||||
fi
|
||||
else
|
||||
echo " [FAIL] Config file missing"
|
||||
ALL_OK=false
|
||||
fi
|
||||
|
||||
if [ -f "$SCHEDULE_FILE" ]; then
|
||||
echo " [OK] Schedule: schedule.txt"
|
||||
else
|
||||
echo " [FAIL] Schedule file missing"
|
||||
ALL_OK=false
|
||||
fi
|
||||
|
||||
if command -v java >/dev/null 2>&1; then
|
||||
echo " [OK] Java: $(java -version 2>&1 | head -1)"
|
||||
else
|
||||
echo " [FAIL] Java not available"
|
||||
ALL_OK=false
|
||||
fi
|
||||
|
||||
if [ -x /usr/local/bin/jruby ] && /usr/local/bin/jruby -v >/dev/null 2>&1; then
|
||||
echo " [OK] JRuby: $(/usr/local/bin/jruby -v 2>/dev/null | head -1)"
|
||||
else
|
||||
echo " [FAIL] Integrated JRuby (/usr/local/bin/jruby) not runnable"
|
||||
ALL_OK=false
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$ALL_OK" = true ]; then
|
||||
echo "=== Installation Complete ==="
|
||||
else
|
||||
echo "=== Installation Finished With Errors ==="
|
||||
echo "Review the [FAIL] items above before starting the service."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo
|
||||
echo "=== Installation complete ==="
|
||||
echo "Next steps:"
|
||||
echo " 1. Place your music in $INSTALL_DIR/music/"
|
||||
echo " 2. Edit $SCHEDULE_FILE with your show/stream schedule"
|
||||
echo " 3. Review/edit $INSTALL_DIR/station.liq"
|
||||
echo " 4. Test: sudo systemctl start icecast2 && sudo systemctl start ${SERVICE_NAME}"
|
||||
echo " 5. Check logs: tail -f $INSTALL_DIR/logs/liquidsoap.log"
|
||||
echo ""
|
||||
echo "Manage shows (run as the liquidsoap user):"
|
||||
RB_PREFIX="sudo -u liquidsoap env GEM_HOME=$GEM_HOME_LOCAL GEM_PATH=$GEM_HOME_LOCAL /usr/local/bin/jruby -S bundle exec"
|
||||
echo " List: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --list-shows --detail"
|
||||
echo " Delete: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --delete-show <slug>"
|
||||
echo " Import OPML: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --import-opml <file.opml>"
|
||||
echo " Add show: $RB_PREFIX $INSTALL_DIR/fetch_podcasts.rb --add-show <feed-url>"
|
||||
echo " JSON state: $RB_PREFIX $INSTALL_DIR/update_playlists.rb --json"
|
||||
echo " 1. Drop MP3/M4A files into $INSTALL_DIR/music/"
|
||||
echo " 2. Edit $INSTALL_DIR/schedule.txt with your show schedule"
|
||||
echo " 3. Review $INSTALL_DIR/station.liq"
|
||||
echo " 4. Start services:"
|
||||
echo " sudo systemctl start icecast2"
|
||||
echo " sudo systemctl start $SERVICE_NAME"
|
||||
|
|
|
|||
|
|
@ -1,459 +1,142 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# install_for_python - Setup for liquidsoap radio automation stack
|
||||
# Target: Linux Mint 22.3 (Ubuntu 24.04 Noble based)
|
||||
#
|
||||
# Usage: sudo ./install_for_python
|
||||
# Must be run from within the target directory (e.g., /srv/audio/)
|
||||
#
|
||||
#!/usr/bin/env bash
|
||||
# install_for_python - Provision the Python-based radio automation stack.
|
||||
# Must be run as root from within the target directory (e.g. /srv/radio/).
|
||||
# Idempotent: safe to re-run.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Determine install directory from script location
|
||||
# -------------------------------------------------------
|
||||
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
|
||||
INSTALL_DIR="$(dirname "$SCRIPT_PATH")"
|
||||
INSTALL_DIR="$(pwd)"
|
||||
SERVICE_NAME="$(basename "$INSTALL_DIR")"
|
||||
LIQUIDSOAP_USER="liquidsoap"
|
||||
VENV="$INSTALL_DIR/.venv"
|
||||
PYTHON_BIN="$VENV/bin/python"
|
||||
|
||||
echo "=== Radio Automation Installer ==="
|
||||
echo "Install directory: $INSTALL_DIR"
|
||||
echo "Service name: $SERVICE_NAME"
|
||||
echo ""
|
||||
echo "=== Radio Automation Installer (Python) ==="
|
||||
echo "Install dir: $INSTALL_DIR"
|
||||
echo "Service name: $SERVICE_NAME"
|
||||
echo
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 0. Validate environment
|
||||
# -------------------------------------------------------
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "ERROR: This script must be run as root (sudo)."
|
||||
exit 1
|
||||
# --- Prompt for config values (pre-fill from existing config.json) ---
|
||||
EXISTING_CONFIG="$INSTALL_DIR/config.json"
|
||||
declare -A CFG
|
||||
CFG[ICE_HOST]="localhost"
|
||||
CFG[ICE_PORT]="7777"
|
||||
CFG[ICE_MOUNT]="/audio.mp3"
|
||||
CFG[ICE_USER]="source"
|
||||
CFG[ICE_PASS]=""
|
||||
CFG[GPODDER_ENABLE]="false"
|
||||
CFG[GPODDER_HOST]="https://gpodder.net"
|
||||
CFG[GPODDER_USER]=""
|
||||
CFG[GPODDER_PASS]=""
|
||||
|
||||
if [[ -f "$EXISTING_CONFIG" ]]; then
|
||||
echo "Existing config.json found; using as defaults."
|
||||
CFG[ICE_HOST]=$(jq -r '.icecast.host // "localhost"' "$EXISTING_CONFIG")
|
||||
CFG[ICE_PORT]=$(jq -r '.icecast.port // 7777' "$EXISTING_CONFIG")
|
||||
CFG[ICE_MOUNT]=$(jq -r '.icecast.mount // "/audio.mp3"' "$EXISTING_CONFIG")
|
||||
CFG[ICE_USER]=$(jq -r '.icecast.username // "source"' "$EXISTING_CONFIG")
|
||||
CFG[ICE_PASS]=$(jq -r '.icecast.password // ""' "$EXISTING_CONFIG")
|
||||
CFG[GPODDER_ENABLE]=$(jq -r '.gpodder.enable // false' "$EXISTING_CONFIG")
|
||||
CFG[GPODDER_HOST]=$(jq -r '.gpodder.host // "https://gpodder.net"' "$EXISTING_CONFIG")
|
||||
CFG[GPODDER_USER]=$(jq -r '.gpodder.username // ""' "$EXISTING_CONFIG")
|
||||
CFG[GPODDER_PASS]=$(jq -r '.gpodder.password // ""' "$EXISTING_CONFIG")
|
||||
fi
|
||||
|
||||
if [ ! -d "$INSTALL_DIR" ]; then
|
||||
echo "ERROR: Install directory $INSTALL_DIR does not exist."
|
||||
exit 1
|
||||
read -rp "Icecast host [$CFG[ICE_HOST]]: " v; CFG[ICE_HOST]=${v:-$CFG[ICE_HOST]}
|
||||
read -rp "Icecast port [$CFG[ICE_PORT]]: " v; CFG[ICE_PORT]=${v:-$CFG[ICE_PORT]}
|
||||
read -rp "Icecast mount [$CFG[ICE_MOUNT]]: " v; CFG[ICE_MOUNT]=${v:-$CFG[ICE_MOUNT]}
|
||||
read -rp "Icecast source username [$CFG[ICE_USER]]: " v; CFG[ICE_USER]=${v:-$CFG[ICE_USER]}
|
||||
read -rsp "Icecast source password [$CFG[ICE_PASS]]: "; echo; CFG[ICE_PASS]=${v:-$CFG[ICE_PASS]}
|
||||
read -rp "Enable gPodder sync? (true/false) [$CFG[GPODDER_ENABLE]]: " v; CFG[GPODDER_ENABLE]=${v:-$CFG[GPODDER_ENABLE]}
|
||||
read -rp "gPodder host [$CFG[GPODDER_HOST]]: " v; CFG[GPODDER_HOST]=${v:-$CFG[GPODDER_HOST]}
|
||||
read -rp "gPodder username [$CFG[GPODDER_USER]]: " v; CFG[GPODDER_USER]=${v:-$CFG[GPODDER_USER]}
|
||||
read -rsp "gPodder password [$CFG[GPODDER_PASS]]: "; echo; CFG[GPODDER_PASS]=${v:-$CFG[GPODDER_PASS]}
|
||||
|
||||
echo
|
||||
echo "Summary:"
|
||||
echo " Icecast: ${CFG[ICE_HOST]}:${CFG[ICE_PORT]}${CFG[ICE_MOUNT]}"
|
||||
echo " gPodder sync: ${CFG[GPODDER_ENABLE]}"
|
||||
read -rp "Proceed? [y/N] " confirm
|
||||
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
||||
|
||||
# --- Install system packages ---
|
||||
echo "Installing system packages..."
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq python3 python3-venv python3-pip jq curl >/dev/null
|
||||
|
||||
# --- Create venv and install Python deps ---
|
||||
if [[ ! -d "$VENV" ]]; then
|
||||
echo "Creating virtualenv at $VENV..."
|
||||
python3 -m venv "$VENV"
|
||||
fi
|
||||
echo "Installing Python dependencies..."
|
||||
"$VENV/bin/pip" install --quiet --upgrade pip
|
||||
"$VENV/bin/pip" install --quiet feedparser requests mutagen
|
||||
|
||||
if [ ! -w "$INSTALL_DIR" ]; then
|
||||
echo "ERROR: Cannot write to $INSTALL_DIR. Check permissions."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REQUIRED_DIRS=(
|
||||
"music"
|
||||
"podcasts"
|
||||
"jingles"
|
||||
"announcements"
|
||||
"playlists"
|
||||
"state"
|
||||
"logs"
|
||||
)
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Helper: prompt with default value
|
||||
# -------------------------------------------------------
|
||||
prompt() {
|
||||
local var_name="$1"
|
||||
local prompt_text="$2"
|
||||
local default_value="${3:-}"
|
||||
local hide_input="${4:-false}"
|
||||
|
||||
if [ -n "$default_value" ]; then
|
||||
read -rp "$prompt_text [$default_value]: " input
|
||||
if [ -z "$input" ]; then
|
||||
eval "$var_name=$default_value"
|
||||
else
|
||||
eval "$var_name=\$input"
|
||||
fi
|
||||
else
|
||||
if [ "$hide_input" = "true" ]; then
|
||||
read -rsp "$prompt_text: " input
|
||||
echo ""
|
||||
eval "$var_name=\$input"
|
||||
else
|
||||
read -rp "$prompt_text: " input
|
||||
eval "$var_name=\$input"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
prompt_confirm() {
|
||||
local var_name="$1"
|
||||
local prompt_text="$2"
|
||||
local default_value="${3:-no}"
|
||||
read -rp "$prompt_text [$default_value]: " input
|
||||
if [ -z "$input" ]; then
|
||||
input="$default_value"
|
||||
fi
|
||||
case "$input" in
|
||||
[Yy]* ) eval "$var_name=true" ;;
|
||||
* ) eval "$var_name=false" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 1. Interactive configuration
|
||||
# -------------------------------------------------------
|
||||
CONFIG_FILE="$INSTALL_DIR/config.json"
|
||||
|
||||
# Load existing config as defaults if present
|
||||
EXISTING_CONFIG=false
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
EXISTING_CONFIG=true
|
||||
echo "--- Loading existing config.json for defaults ---"
|
||||
ICECAST_HOST_DEFAULT=$(jq -r '.icecast.host // "localhost"' "$CONFIG_FILE")
|
||||
ICECAST_PORT_DEFAULT=$(jq -r '.icecast.port // 7777' "$CONFIG_FILE")
|
||||
ICECAST_MOUNT_DEFAULT=$(jq -r '.icecast.mount // "/audio.mp3"' "$CONFIG_FILE")
|
||||
ICECAST_USERNAME_DEFAULT=$(jq -r '.icecast.username // "source"' "$CONFIG_FILE")
|
||||
GPODDER_ENABLE_DEFAULT=$(jq -r 'if .gpodder.enable == true then "yes" else "no" end' "$CONFIG_FILE")
|
||||
GPODDER_HOST_DEFAULT=$(jq -r '.gpodder.host // "https://gpodder.net"' "$CONFIG_FILE")
|
||||
GPODDER_USERNAME_DEFAULT=$(jq -r '.gpodder.username // ""' "$CONFIG_FILE")
|
||||
else
|
||||
ICECAST_HOST_DEFAULT="localhost"
|
||||
ICECAST_PORT_DEFAULT="7777"
|
||||
ICECAST_MOUNT_DEFAULT="/audio.mp3"
|
||||
ICECAST_USERNAME_DEFAULT="source"
|
||||
GPODDER_ENABLE_DEFAULT="no"
|
||||
GPODDER_HOST_DEFAULT="https://gpodder.net"
|
||||
GPODDER_USERNAME_DEFAULT=""
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- Icecast Settings ---"
|
||||
echo ""
|
||||
|
||||
ICECAST_HOST=""
|
||||
ICECAST_PORT=""
|
||||
ICECAST_MOUNT=""
|
||||
ICECAST_USERNAME=""
|
||||
ICECAST_PASSWORD=""
|
||||
|
||||
while true; do
|
||||
prompt ICECAST_HOST "Icecast host" "$ICECAST_HOST_DEFAULT"
|
||||
[[ "$ICECAST_HOST" =~ ^[a-zA-Z0-9._-]+$ ]] && break
|
||||
echo "Invalid hostname. Try again."
|
||||
# --- Create directory structure ---
|
||||
for d in music podcasts jingles announcements playlists state logs; do
|
||||
mkdir -p "$INSTALL_DIR/$d"
|
||||
done
|
||||
|
||||
while true; do
|
||||
prompt ICECAST_PORT "Source port" "$ICECAST_PORT_DEFAULT"
|
||||
[[ "$ICECAST_PORT" =~ ^[0-9]{1,5}$ ]] && (( ICECAST_PORT >= 1 )) && (( ICECAST_PORT <= 65535 )) && break
|
||||
echo "Port must be a number between 1 and 65535. Try again."
|
||||
done
|
||||
|
||||
while true; do
|
||||
prompt ICECAST_MOUNT "Mount point" "$ICECAST_MOUNT_DEFAULT"
|
||||
[[ "$ICECAST_MOUNT" == /* ]] && break
|
||||
echo "Mount point must start with /. Try again."
|
||||
done
|
||||
|
||||
prompt ICECAST_USERNAME "Source username" "$ICECAST_USERNAME_DEFAULT"
|
||||
|
||||
while true; do
|
||||
prompt ICECAST_PASSWORD "Source password" "" "true"
|
||||
[ -n "$ICECAST_PASSWORD" ] && break
|
||||
echo "Password cannot be empty. Try again."
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "--- gPodder.net Sync Settings ---"
|
||||
echo ""
|
||||
|
||||
GPODDER_ENABLE=false
|
||||
if [ "$GPODDER_ENABLE_DEFAULT" = "yes" ]; then
|
||||
prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "yes"
|
||||
else
|
||||
prompt_confirm GPODDER_ENABLE "Enable gPodder.net subscription sync?" "no"
|
||||
fi
|
||||
|
||||
GPODDER_HOST=""
|
||||
GPODDER_USERNAME=""
|
||||
GPODDER_PASSWORD=""
|
||||
|
||||
if [ "$GPODDER_ENABLE" = "true" ]; then
|
||||
prompt GPODDER_HOST "gPodder host" "$GPODDER_HOST_DEFAULT"
|
||||
while true; do
|
||||
prompt GPODDER_USERNAME "gPodder username/email" "$GPODDER_USERNAME_DEFAULT"
|
||||
[ -n "$GPODDER_USERNAME" ] && break
|
||||
echo "Username cannot be empty. Try again."
|
||||
done
|
||||
while true; do
|
||||
prompt GPODDER_PASSWORD "gPodder password" "" "true"
|
||||
[ -n "$GPODDER_PASSWORD" ] && break
|
||||
echo "Password cannot be empty. Try again."
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- Summary ---"
|
||||
echo " Icecast: ${ICECAST_HOST}:${ICECAST_PORT}${ICECAST_MOUNT} (user: ${ICECAST_USERNAME})"
|
||||
echo " gPodder: $( [ "$GPODDER_ENABLE" = "true" ] && echo "enabled (${GPODDER_USERNAME}@${GPODDER_HOST})" || echo "disabled" )"
|
||||
echo ""
|
||||
|
||||
CONFIRM_INSTALL=false
|
||||
prompt_confirm CONFIRM_INSTALL "Proceed with these settings?" "yes"
|
||||
if [ "$CONFIRM_INSTALL" != "true" ]; then
|
||||
echo "Aborted by user."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 2. System packages
|
||||
# -------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- Installing system packages ---"
|
||||
apt-get update
|
||||
apt-get install -y \
|
||||
liquidsoap \
|
||||
icecast2 \
|
||||
jq \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
ffmpeg \
|
||||
lame \
|
||||
libtag1-dev \
|
||||
curl \
|
||||
ca-certificates
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 3. Python virtual environment
|
||||
# -------------------------------------------------------
|
||||
echo "--- Setting up Python virtualenv ---"
|
||||
VENV_DIR="$INSTALL_DIR/.venv"
|
||||
python3 -m venv "$VENV_DIR"
|
||||
"$VENV_DIR/bin/pip" install --upgrade pip
|
||||
"$VENV_DIR/bin/pip" install \
|
||||
feedparser \
|
||||
requests \
|
||||
mutagen
|
||||
|
||||
PYTHON_BIN="$VENV_DIR/bin/python3"
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 4. Create and verify directory structure
|
||||
# -------------------------------------------------------
|
||||
echo "--- Creating directory structure ---"
|
||||
MISSING=()
|
||||
for dir in "${REQUIRED_DIRS[@]}"; do
|
||||
full_path="$INSTALL_DIR/$dir"
|
||||
if [ ! -d "$full_path" ]; then
|
||||
mkdir -p "$full_path"
|
||||
MISSING+=("$dir")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#MISSING[@]} -gt 0 ]; then
|
||||
echo " Created: ${MISSING[*]}"
|
||||
else
|
||||
echo " All directories already present."
|
||||
fi
|
||||
|
||||
FAILED=()
|
||||
for dir in "${REQUIRED_DIRS[@]}"; do
|
||||
if [ ! -d "$INSTALL_DIR/$dir" ]; then
|
||||
FAILED+=("$dir")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
echo "ERROR: Failed to create directories: ${FAILED[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 5. Generate config.json from collected settings
|
||||
# -------------------------------------------------------
|
||||
echo "--- Generating config.json ---"
|
||||
|
||||
cat > "$CONFIG_FILE" << EOF
|
||||
# --- Generate config.json ---
|
||||
cat > "$INSTALL_DIR/config.json" <<EOF
|
||||
{
|
||||
"icecast": {
|
||||
"host": "${ICECAST_HOST}",
|
||||
"port": ${ICECAST_PORT},
|
||||
"mount": "${ICECAST_MOUNT}",
|
||||
"username": "${ICECAST_USERNAME}",
|
||||
"password": "${ICECAST_PASSWORD}"
|
||||
"host": "${CFG[ICE_HOST]}",
|
||||
"port": ${CFG[ICE_PORT]},
|
||||
"mount": "${CFG[ICE_MOUNT]}",
|
||||
"username": "${CFG[ICE_USER]}",
|
||||
"password": "${CFG[ICE_PASS]}"
|
||||
},
|
||||
"gpodder": {
|
||||
"enable": ${GPODDER_ENABLE},
|
||||
"host": "${GPODDER_HOST}",
|
||||
"username": "${GPODDER_USERNAME}",
|
||||
"password": "${GPODDER_PASSWORD}"
|
||||
"enable": ${CFG[GPODDER_ENABLE]},
|
||||
"host": "${CFG[GPODDER_HOST]}",
|
||||
"username": "${CFG[GPODDER_USER]}",
|
||||
"password": "${CFG[GPODDER_PASS]}"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
chmod 600 "$INSTALL_DIR/config.json"
|
||||
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
echo " Written: $CONFIG_FILE"
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 6. Create schedule.txt template (if not present)
|
||||
# -------------------------------------------------------
|
||||
SCHEDULE_FILE="$INSTALL_DIR/schedule.txt"
|
||||
if [ ! -f "$SCHEDULE_FILE" ]; then
|
||||
echo "--- Creating schedule.txt template ---"
|
||||
cat > "$SCHEDULE_FILE" << 'EOF'
|
||||
# Radio Schedule
|
||||
# Format: min hour dom mon dow TYPE TARGET [RUNLENGTH_SECONDS]
|
||||
# TYPE: show | stream
|
||||
# RUNLENGTH required for stream entries, ignored for show entries
|
||||
#
|
||||
# Examples:
|
||||
# 0 8 * * 2 show hardcore_history
|
||||
# 0 6 * * 1 stream http://example.org:8000/live.mp3 3600
|
||||
EOF
|
||||
else
|
||||
echo "--- schedule.txt already exists, skipping ---"
|
||||
# --- Ensure liquidsoap user exists ---
|
||||
if ! id liquidsoap &>/dev/null; then
|
||||
useradd --system --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin liquidsoap
|
||||
fi
|
||||
chown -R liquidsoap:liquidsoap "$INSTALL_DIR"
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 7. Verify required project files exist
|
||||
# -------------------------------------------------------
|
||||
echo "--- Verifying project files ---"
|
||||
PROJECT_FILES=("station.liq" "fetch_podcasts.py" "update_playlists.py")
|
||||
FILE_ERRORS=()
|
||||
for f in "${PROJECT_FILES[@]}"; do
|
||||
if [ ! -f "$INSTALL_DIR/$f" ]; then
|
||||
FILE_ERRORS+=("$f")
|
||||
else
|
||||
echo " Found: $f"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#FILE_ERRORS[@]} -gt 0 ]; then
|
||||
echo "WARNING: Missing expected files: ${FILE_ERRORS[*]}"
|
||||
echo " The service will not start until these are in place."
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 8. Create systemd service named after the directory
|
||||
# -------------------------------------------------------
|
||||
echo "--- Creating systemd service: ${SERVICE_NAME}.service ---"
|
||||
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF
|
||||
# --- Write systemd service ---
|
||||
cat > /etc/systemd/system/"$SERVICE_NAME".service <<EOF
|
||||
[Unit]
|
||||
Description=Liquidsoap radio automation (${SERVICE_NAME})
|
||||
Description=Liquidsoap radio station ($SERVICE_NAME)
|
||||
After=network.target icecast2.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${LIQUIDSOAP_USER}
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
ExecStart=/usr/bin/liquidsoap ${INSTALL_DIR}/station.liq
|
||||
User=liquidsoap
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
ExecStart=/usr/bin/liquidsoap $INSTALL_DIR/station.liq
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=append:${INSTALL_DIR}/logs/liquidsoap.log
|
||||
StandardError=append:${INSTALL_DIR}/logs/liquidsoap.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 9. Create cron jobs (hourly fetch + hourly playlist update)
|
||||
# -------------------------------------------------------
|
||||
echo "--- Setting up cron jobs ---"
|
||||
# --- Set up cron jobs (deduplicated) ---
|
||||
CRON_FETCH="0 * * * * sudo -u liquidsoap $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py >> $INSTALL_DIR/logs/cron_fetch.log 2>&1"
|
||||
CRON_UPDATE="30 * * * * sudo -u liquidsoap $PYTHON_BIN $INSTALL_DIR/update_playlists.py >> $INSTALL_DIR/logs/cron_update.log 2>&1"
|
||||
|
||||
FETCH_CRON="0 * * * * cd $INSTALL_DIR && $PYTHON_BIN fetch_podcasts.py >> $INSTALL_DIR/logs/fetch.log 2>&1"
|
||||
UPDATE_CRON="30 * * * * cd $INSTALL_DIR && $PYTHON_BIN update_playlists.py >> $INSTALL_DIR/logs/update.log 2>&1"
|
||||
|
||||
# Pull existing crontab (empty if none)
|
||||
EXISTING_CRON=$(crontab -l -u "$LIQUIDSOAP_USER" 2>/dev/null || true)
|
||||
|
||||
# Remove any stale entries for these scripts to avoid duplicates on re-runs
|
||||
CLEANED_CRON=$(echo "$EXISTING_CRON" | grep -v "fetch_podcasts.py\|update_playlists.py" || true)
|
||||
|
||||
# Add fetch job if not present
|
||||
if ! echo "$EXISTING_CRON" | grep -q "fetch_podcasts.py"; then
|
||||
CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON
|
||||
}$FETCH_CRON"
|
||||
echo " Added: fetch_podcasts.py (hourly)"
|
||||
else
|
||||
echo " Skipped: fetch_podcasts.py job already exists"
|
||||
fi
|
||||
|
||||
# Add update job if not present
|
||||
if ! echo "$EXISTING_CRON" | grep -q "update_playlists.py"; then
|
||||
CLEANED_CRON="${CLEANED_CRON:+$CLEANED_CRON
|
||||
}$UPDATE_CRON"
|
||||
echo " Added: update_playlists.py (hourly at :30)"
|
||||
else
|
||||
echo " Skipped: update_playlists.py job already exists"
|
||||
fi
|
||||
|
||||
# Write back the combined crontab
|
||||
echo "$CLEANED_CRON" | crontab -u "$LIQUIDSOAP_USER" -
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 10. Permissions
|
||||
# -------------------------------------------------------
|
||||
echo "--- Setting ownership and permissions ---"
|
||||
id -u "$LIQUIDSOAP_USER" &>/dev/null || useradd --system --shell /usr/sbin/nologin "$LIQUIDSOAP_USER"
|
||||
chown -R "${LIQUIDSOAP_USER}:${LIQUIDSOAP_USER}" "$INSTALL_DIR"
|
||||
chmod 700 "$INSTALL_DIR/state"
|
||||
chmod 700 "$INSTALL_DIR/logs"
|
||||
(crontab -l 2>/dev/null | grep -v "fetch_podcasts.py"; echo "$CRON_FETCH") | crontab -
|
||||
(crontab -l 2>/dev/null | grep -v "update_playlists.py"; echo "$CRON_UPDATE") | crontab -
|
||||
|
||||
# --- Reload and enable ---
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 11. Final verification
|
||||
# -------------------------------------------------------
|
||||
echo ""
|
||||
echo "=== Final Verification ==="
|
||||
ALL_OK=true
|
||||
for dir in "${REQUIRED_DIRS[@]}"; do
|
||||
if [ -d "$INSTALL_DIR/$dir" ] && [ -r "$INSTALL_DIR/$dir" ] && [ -w "$INSTALL_DIR/$dir" ]; then
|
||||
printf " [OK] %-20s\n" "$dir/"
|
||||
else
|
||||
printf " [FAIL] %-20s (missing or inaccessible)\n" "$dir/"
|
||||
ALL_OK=false
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then
|
||||
echo " [OK] Service file: ${SERVICE_NAME}.service"
|
||||
else
|
||||
echo " [FAIL] Service file not created"
|
||||
ALL_OK=false
|
||||
fi
|
||||
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
if jq empty "$CONFIG_FILE" 2>/dev/null; then
|
||||
echo " [OK] Config: config.json (valid JSON)"
|
||||
else
|
||||
echo " [FAIL] Config: config.json (invalid JSON)"
|
||||
ALL_OK=false
|
||||
fi
|
||||
else
|
||||
echo " [FAIL] Config file missing"
|
||||
ALL_OK=false
|
||||
fi
|
||||
|
||||
if [ -f "$SCHEDULE_FILE" ]; then
|
||||
echo " [OK] Schedule: schedule.txt"
|
||||
else
|
||||
echo " [FAIL] Schedule file missing"
|
||||
ALL_OK=false
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$ALL_OK" = true ]; then
|
||||
echo "=== Installation Complete ==="
|
||||
else
|
||||
echo "=== Installation Finished With Errors ==="
|
||||
echo "Review the [FAIL] items above before starting the service."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo
|
||||
echo "=== Installation complete ==="
|
||||
echo "Next steps:"
|
||||
echo " 1. Place your music in $INSTALL_DIR/music/"
|
||||
echo " 2. Edit $SCHEDULE_FILE with your show/stream schedule"
|
||||
echo " 3. Review/edit $INSTALL_DIR/station.liq"
|
||||
echo " 4. Test: sudo systemctl start icecast2 && sudo systemctl start ${SERVICE_NAME}"
|
||||
echo " 5. Check logs: tail -f $INSTALL_DIR/logs/liquidsoap.log"
|
||||
echo ""
|
||||
echo "Manage shows:"
|
||||
echo " List: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --list-shows --detail"
|
||||
echo " Delete: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --delete-show <slug>"
|
||||
echo " Import OPML: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --import-opml <file.opml>"
|
||||
echo " Add show: $PYTHON_BIN $INSTALL_DIR/fetch_podcasts.py --add-show <feed-url>"
|
||||
echo " JSON state: $PYTHON_BIN $INSTALL_DIR/update_playlists.py --json"
|
||||
echo " 1. Drop MP3/M4A files into $INSTALL_DIR/music/"
|
||||
echo " 2. Edit $INSTALL_DIR/schedule.txt with your show schedule"
|
||||
echo " 3. Review $INSTALL_DIR/station.liq"
|
||||
echo " 4. Start services:"
|
||||
echo " sudo systemctl start icecast2"
|
||||
echo " sudo systemctl start $SERVICE_NAME"
|
||||
|
|
|
|||
188
station.liq
188
station.liq
|
|
@ -1,116 +1,108 @@
|
|||
# station.liq - Main liquidsoap configuration
|
||||
# All paths are resolved relative to this file's location.
|
||||
# Move the whole directory tree and nothing else needs changing.
|
||||
#!/usr/bin/env liquidsoap
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Resolve root directory from script location
|
||||
# -------------------------------------------------------
|
||||
let ROOT = configure.bindir()
|
||||
# ---------------------------------------------------------------
|
||||
# station.liq - Liquidsoap configuration for radio automation
|
||||
# Auto-detects its own location so all paths are relative.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
let MUSIC_DIR = ROOT ^ "/music"
|
||||
let PODCASTS_DIR = ROOT ^ "/podcasts"
|
||||
let PLAYLISTS_DIR = ROOT ^ "/playlists"
|
||||
let SCHEDULE_FILE = ROOT ^ "/schedule.txt"
|
||||
let CONFIG_FILE = ROOT ^ "/config.json"
|
||||
configure.bindir()
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Load Icecast credentials from config.json
|
||||
# -------------------------------------------------------
|
||||
let cfg = json.from_file(CONFIG_FILE)
|
||||
let ic_host = json.(cfg.icecast.host)
|
||||
let ic_port = int_of_string(json.(cfg.icecast.port))
|
||||
let ic_mount = json.(cfg.icecast.mount)
|
||||
let ic_user = json.(cfg.icecast.username)
|
||||
let ic_pass = json.(cfg.icecast.password)
|
||||
set("log.file.path", "#{bindir()}/logs/liquidsoap.log")
|
||||
set("log.stdout", true)
|
||||
set("log.level", 3)
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Background music source
|
||||
# -------------------------------------------------------
|
||||
let music =
|
||||
playlist.recursive(
|
||||
mode="random",
|
||||
duration=3600,
|
||||
path=MUSIC_DIR,
|
||||
extensions=["mp3", "m4a", "ogg", "flac"]
|
||||
# --- Load Icecast credentials from config.json ---
|
||||
let json.parse (cfg : {
|
||||
icecast: {
|
||||
host: string,
|
||||
port: int,
|
||||
mount: string,
|
||||
username: string,
|
||||
password: string
|
||||
},
|
||||
gpodder: {
|
||||
enable: bool,
|
||||
host: string,
|
||||
username: string,
|
||||
password: string
|
||||
}
|
||||
}) = file.contents("#{bindir()}/config.json")
|
||||
|
||||
let ic_host = cfg.icecast.host
|
||||
let ic_port = cfg.icecast.port
|
||||
let ic_mount = cfg.icecast.mount
|
||||
let ic_username = cfg.icecast.username
|
||||
let ic_password = cfg.icecast.password
|
||||
|
||||
# --- Background music library (recursive scan for mp3/m4a) ---
|
||||
music_dir = "#{bindir()}/music"
|
||||
music_playlist =
|
||||
request.cue(
|
||||
playlist(
|
||||
recurse=true,
|
||||
pattern="\\.(mp3|m4a)$",
|
||||
"#{music_dir}"
|
||||
)
|
||||
)
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Scheduled content via request queue
|
||||
# -------------------------------------------------------
|
||||
def q = ref []
|
||||
# --- Scheduled content: request queue fed by schedule.txt ---
|
||||
sched_queue = request.queue(id="scheduler")
|
||||
|
||||
def push_request(req) =
|
||||
q := req :: (!q)
|
||||
# --- Jingles / announcements (optional) ---
|
||||
jingle_dir = "#{bindir()}/jingles"
|
||||
announce_dir = "#{bindir()}/announcements"
|
||||
|
||||
# Build a show source from its .pls file (written by update_playlists.py)
|
||||
def show_source(slug) =
|
||||
let pls_path = PLAYLISTS_DIR ^ "/" ^ slug ^ ".pls"
|
||||
if file.test(pls_path) then
|
||||
log("# Playing scheduled show: #{slug}")
|
||||
Some(request.create(pls_path))
|
||||
def has_audio(dir) =
|
||||
try
|
||||
let l = list.filter(fun(f) -> regexp("\\.(mp3|m4a)$").test(f), ls.dir(dir))
|
||||
list.length(l) > 0
|
||||
catch _ do
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
jingles_src =
|
||||
if has_audio(jingle_dir) then
|
||||
mksafe(request.cue(playlist(recurse=true, pattern="\\.(mp3|m4a)$", jingle_dir)))
|
||||
else
|
||||
log("! No playlist found for show: #{slug}, skipping")
|
||||
None
|
||||
null()
|
||||
end
|
||||
|
||||
# Parse schedule.txt and register cron-triggered tasks
|
||||
def load_schedule() =
|
||||
try
|
||||
if not(file.test(SCHEDULE_FILE)) then
|
||||
log("WARNING: schedule.txt not found at #{SCHEDULE_FILE}")
|
||||
else
|
||||
let lines = file.lines(SCHEDULE_FILE)
|
||||
List.iter(fun line ->
|
||||
let t = String.strip(line)
|
||||
if t != "" && not(String.starts_with(t, "#")) then
|
||||
let p = String.split(t, sep=" ")
|
||||
|> List.filter(fun x -> String.strip(x) != "")
|
||||
if List.length(p) >= 7 then
|
||||
let cron_expr = String.concat(sep=" ", List.take(p, 5))
|
||||
let stype = List.nth(p, 5)
|
||||
let target = List.nth(p, 6)
|
||||
match stype with
|
||||
| "show" ->
|
||||
cron.add(id=target, cron_expr, fun () ->
|
||||
match show_source(target) with
|
||||
| Some(r) -> push_request r
|
||||
| None -> ()
|
||||
)
|
||||
| "stream" ->
|
||||
let runlen_str =
|
||||
if List.length(p) > 7 then List.nth(p, 7) else "3600"
|
||||
let runlen = int_of_string(runlen_str)
|
||||
cron.add(id=target, cron_expr, fun () ->
|
||||
log("Triggering stream: #{target} (#{runlen}s)")
|
||||
# For external streams, create a request with duration cap
|
||||
let s = single.file(fallback=false, target)
|
||||
push_request(request.create(target))
|
||||
)
|
||||
| _ ->
|
||||
log("Unknown schedule type '#{stype}' for target '#{target}', ignoring")
|
||||
end
|
||||
else
|
||||
log("Malformed schedule line (need at least 7 fields): #{t}")
|
||||
end
|
||||
end
|
||||
) lines
|
||||
log("Schedule loaded from #{SCHEDULE_FILE}")
|
||||
with e ->
|
||||
log("ERROR loading schedule: #{e}")
|
||||
announcements_src =
|
||||
if has_audio(announce_dir) then
|
||||
mksafe(request.cue(playlist(recurse=true, pattern="\\.(mp3|m4a)$", announce_dir)))
|
||||
else
|
||||
null()
|
||||
end
|
||||
|
||||
# Load schedule once at startup
|
||||
thread.run(at=now(), fun () -> load_schedule())
|
||||
# --- Assemble the stream ---
|
||||
primary = fallback(track_sensitive=false, [sched_queue, music_playlist])
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Output: scheduled content over background music fallback
|
||||
# -------------------------------------------------------
|
||||
content =
|
||||
match jingles_src with
|
||||
| null() => primary
|
||||
| src => random(weights=[1, 8], [src, primary])
|
||||
end
|
||||
|
||||
final_content =
|
||||
match announcements_src with
|
||||
| null() => content
|
||||
| src => fallback(track_sensitive=false, [src, content])
|
||||
end
|
||||
|
||||
radio = normalize(final_content)
|
||||
|
||||
# --- Output to Icecast ---
|
||||
output.icecast(
|
||||
%mp3(bitrate=128),
|
||||
%mp3(bitrate=128, samplerate=44100, stereo=true),
|
||||
host=ic_host,
|
||||
port=ic_port,
|
||||
user=ic_user,
|
||||
password=ic_pass,
|
||||
user=ic_username,
|
||||
password=ic_password,
|
||||
mount=ic_mount,
|
||||
fallback=music,
|
||||
request.queue(q)
|
||||
name="Radio Station",
|
||||
description="Automated internet radio",
|
||||
genre="Various",
|
||||
public=true,
|
||||
radio
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,26 +1,162 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regenerate per-show playlist files based on playback history."""
|
||||
"""
|
||||
update_playlists.py - Regenerate per-show .pls playlists based on playback history.
|
||||
Runs via cron hourly at :30.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
AUDIO_ROOT = Path(__file__).resolve().parent
|
||||
PLAYLISTS_DIR = AUDIO_ROOT / "playlists"
|
||||
STATE_DB = AUDIO_ROOT / "state" / "played.db"
|
||||
SUBS_DB = AUDIO_ROOT / "state" / "subscriptions.db"
|
||||
SHOWS_DIR = AUDIO_ROOT / "podcasts"
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
STATE_DIR = ROOT / "state"
|
||||
SUBS_DB = STATE_DIR / "subscriptions.db"
|
||||
PLAYED_DB = STATE_DIR / "played.db"
|
||||
PODCASTS_DIR = ROOT / "podcasts"
|
||||
PLAYLISTS_DIR = ROOT / "playlists"
|
||||
LOGS_DIR = ROOT / "logs"
|
||||
|
||||
AUDIO_EXTS = {".mp3", ".m4a"}
|
||||
|
||||
def get_played_db():
|
||||
STATE_DB.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(STATE_DB))
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS played (
|
||||
filename TEXT PRIMARY KEY,
|
||||
show TEXT,
|
||||
played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)""")
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(LOGS_DIR / "update.log"),
|
||||
logging.StreamHandler(sys.stdout),
|
||||
],
|
||||
)
|
||||
log = logging.getLogger("update_playlists")
|
||||
|
||||
def open_subs_db():
|
||||
conn = sqlite3.connect(SUBS_DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def open_played_db():
|
||||
conn = sqlite3.connect(PLAYED_DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
show_slug TEXT NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
title TEXT,
|
||||
file_path TEXT,
|
||||
duration_seconds INTEGER,
|
||||
played_at TEXT,
|
||||
UNIQUE(show_slug, guid)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
def find_audio_files(directory):
|
||||
"""Recursively scan for .mp3/.m4a files."""
|
||||
results = []
|
||||
if not directory.exists():
|
||||
return results
|
||||
for p in sorted(directory.rglob("*")):
|
||||
if p.is_file() and p.suffix.lower() in AUDIO_EXTS:
|
||||
results.append(str(p))
|
||||
return results
|
||||
|
||||
def select_unplayed_episode(slug, played_db):
|
||||
"""Pick one unplayed episode for the show, falling back to least-recently-played."""
|
||||
files = find_audio_files(PODCASTS_DIR / slug)
|
||||
if not files:
|
||||
return None
|
||||
|
||||
played_rows = played_db.execute(
|
||||
"SELECT file_path, played_at FROM episodes WHERE show_slug = ? AND played_at IS NOT NULL",
|
||||
(slug,),
|
||||
).fetchall()
|
||||
played_paths = {row["file_path"]: row["played_at"] for row in played_rows}
|
||||
|
||||
unplayed = [f for f in files if f not in played_paths]
|
||||
if unplayed:
|
||||
return unplayed[0]
|
||||
|
||||
# All played: pick least recently played
|
||||
if played_paths:
|
||||
return min(played_paths.items(), key=lambda kv: (kv[1] or ""))[0]
|
||||
return files[0]
|
||||
|
||||
def write_pls(filepath, out_path):
|
||||
"""Write a .pls playlist pointing at a single file."""
|
||||
abs_path = str(Path(filepath).resolve())
|
||||
content = f"[playlist]\nFile1={abs_path}\nTitle1=Radio Episode\nLength1=-1\nNumberOfEntries=1\nVersion=2\n"
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(content)
|
||||
|
||||
def mark_as_played(slug, filepath, duration, played_db):
|
||||
played_db.execute(
|
||||
"UPDATE episodes SET played_at = datetime('now') WHERE show_slug = ? AND file_path = ?",
|
||||
(slug, filepath),
|
||||
)
|
||||
played_db.commit()
|
||||
|
||||
def update_all():
|
||||
subs_db = open_subs_db()
|
||||
played_db = open_played_db()
|
||||
shows = subs_db.execute("SELECT slug, name FROM shows ORDER BY name").fetchall()
|
||||
|
||||
for show in shows:
|
||||
slug = show["slug"]
|
||||
selected = select_unplayed_episode(slug, played_db)
|
||||
if selected is None:
|
||||
log.info("%s: no audio files found, skipping.", slug)
|
||||
continue
|
||||
|
||||
duration_row = played_db.execute(
|
||||
"SELECT duration_seconds FROM episodes WHERE show_slug = ? AND file_path = ?",
|
||||
(slug, selected),
|
||||
).fetchone()
|
||||
duration = duration_row["duration_seconds"] if duration_row else None
|
||||
|
||||
out_pls = PLAYLISTS_DIR / f"{slug}.pls"
|
||||
write_pls(selected, out_pls)
|
||||
mark_as_played(slug, selected, duration, played_db)
|
||||
log.info("%s: queued %s", slug, Path(selected).name)
|
||||
|
||||
subs_db.close()
|
||||
played_db.close()
|
||||
|
||||
def json_summary():
|
||||
subs_db = open_subs_db()
|
||||
played_db = open_played_db()
|
||||
shows = subs_db.execute("SELECT slug FROM shows ORDER BY name").fetchall()
|
||||
summary = {}
|
||||
for show in shows:
|
||||
slug = show["slug"]
|
||||
files = find_audio_files(PODCASTS_DIR / slug)
|
||||
played_count = played_db.execute(
|
||||
"SELECT COUNT(*) as c FROM episodes WHERE show_slug = ? AND played_at IS NOT NULL",
|
||||
(slug,),
|
||||
).fetchone()["c"]
|
||||
summary[slug] = {"total_files": len(files), "played_count": played_count}
|
||||
subs_db.close()
|
||||
played_db.close()
|
||||
print(json.dumps(summary, indent=2))
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Playlist updater for radio automation")
|
||||
parser.add_argument("--json", action="store_true", help="Emit JSON summary and exit")
|
||||
args = parser.parse_args()
|
||||
|
||||
STATE_DIR.mkdir(exist_ok=True)
|
||||
LOGS_DIR.mkdir(exist_ok=True)
|
||||
PLAYLISTS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
if args.json:
|
||||
json_summary()
|
||||
else:
|
||||
update_all()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,138 +1,154 @@
|
|||
#!/usr/bin/env jruby
|
||||
# frozen_string_literal: true
|
||||
#
|
||||
# update_playlists.rb - Regenerate per-show playlist files based on playback history.
|
||||
# JRuby-compatible (Ruby 3.1+ baseline).
|
||||
|
||||
require "json"
|
||||
require "sqlite3"
|
||||
require "fileutils"
|
||||
require "time"
|
||||
require "optparse"
|
||||
|
||||
AUDIO_ROOT = Pathname.new(File.expand_path(__dir__))
|
||||
PLAYLISTS_DIR = AUDIO_ROOT.join("playlists")
|
||||
STATE_DB = AUDIO_ROOT.join("state/played.db")
|
||||
SUBS_DB = AUDIO_ROOT.join("state/subscriptions.db")
|
||||
SHOWS_DIR = AUDIO_ROOT.join("podcasts")
|
||||
module RadioAutomation
|
||||
ROOT = File.expand_path("..", __dir__)
|
||||
STATE_DIR = File.join(ROOT, "state")
|
||||
SUBS_DB = File.join(STATE_DIR, "subscriptions.db")
|
||||
PLAYED_DB = File.join(STATE_DIR, "played.db")
|
||||
PODCASTS_DIR = File.join(ROOT, "podcasts")
|
||||
PLAYLISTS_DIR = File.join(ROOT, "playlists")
|
||||
LOGS_DIR = File.join(ROOT, "logs")
|
||||
AUDIO_EXTS = [".mp3", ".m4a"]
|
||||
|
||||
module Radio
|
||||
class PlaylistUpdater
|
||||
def initialize
|
||||
FileUtils.mkdir_p(PLAYLISTS_DIR)
|
||||
FileUtils.mkdir_p(STATE_DB.dirname)
|
||||
@played_db = connect_played_db
|
||||
@subs_db = connect_subs_db
|
||||
end
|
||||
def self.log_info(msg)
|
||||
puts "#{Time.now.iso8601} [INFO] #{msg}"
|
||||
append_log("update.log", msg)
|
||||
end
|
||||
|
||||
attr_reader :played_db, :subs_db
|
||||
def self.append_log(filename, msg)
|
||||
FileUtils.mkdir_p(LOGS_DIR)
|
||||
File.open(File.join(LOGS_DIR, filename), "a") { |f| f.puts msg }
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
def connect_played_db
|
||||
conn = SQLite3::Database.new(STATE_DB.to_s)
|
||||
conn.execute(<<~SQL)
|
||||
CREATE TABLE IF NOT EXISTS played (
|
||||
filename TEXT PRIMARY KEY,
|
||||
show TEXT,
|
||||
played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
SQL
|
||||
conn
|
||||
end
|
||||
def self.open_subs_db
|
||||
db = SQLite3::Database.new(SUBS_DB)
|
||||
db.results_as_hash = true
|
||||
db
|
||||
end
|
||||
|
||||
def connect_subs_db
|
||||
conn = SQLite3::Database.new(SUBS_DB.to_s)
|
||||
conn
|
||||
end
|
||||
|
||||
# Build a .pls for each show: pick an unplayed episode, mark it played.
|
||||
def regenerate_all
|
||||
shows = subs_db.execute("SELECT name, slug FROM shows ORDER BY name")
|
||||
if shows.empty?
|
||||
puts "No shows registered."
|
||||
return
|
||||
end
|
||||
|
||||
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]
|
||||
def self.open_played_db
|
||||
db = SQLite3::Database.new(PLAYED_DB)
|
||||
db.results_as_hash = true
|
||||
db.execute <<-SQL
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
show_slug TEXT NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
title TEXT,
|
||||
file_path TEXT,
|
||||
duration_seconds INTEGER,
|
||||
played_at TEXT,
|
||||
UNIQUE(show_slug, guid)
|
||||
)
|
||||
played_db.commit
|
||||
SQL
|
||||
db
|
||||
end
|
||||
|
||||
puts " #{slug}: queued #{File.basename(chosen)}"
|
||||
true
|
||||
def self.find_audio_files(directory)
|
||||
return [] unless Dir.exist?(directory)
|
||||
Dir.glob(File.join(directory, "**", "*")).select do |f|
|
||||
File.file?(f) && AUDIO_EXTS.any? { |ext| f.end_with?(ext) }
|
||||
end.sort
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def lru_episode(candidates, slug)
|
||||
played_rows = played_db.execute(
|
||||
"SELECT filename, played_at FROM played WHERE show=? ORDER BY played_at ASC", slug
|
||||
)
|
||||
played_map = played_rows.to_h { |fname, ts| [File.basename(fname), ts] }
|
||||
candidates.min_by { |c| played_map[File.basename(c)] || Time.at(0) }
|
||||
end
|
||||
def self.write_pls(filepath, out_path)
|
||||
abs = File.absolute_path(filepath)
|
||||
content = "[playlist]\nFile1=#{abs}\nTitle1=Radio Episode\nLength1=-1\nNumberOfEntries=1\nVersion=2\n"
|
||||
FileUtils.mkdir_p(File.dirname(out_path))
|
||||
File.write(out_path, content)
|
||||
end
|
||||
|
||||
def dump_json
|
||||
shows = subs_db.execute("SELECT name, slug FROM shows ORDER BY name")
|
||||
out = {}
|
||||
shows.each do |_name, slug|
|
||||
show_dir = SHOWS_DIR.join(slug)
|
||||
next unless Dir.exist?(show_dir)
|
||||
def self.mark_as_played(slug, filepath, played_db)
|
||||
played_db.execute(
|
||||
"UPDATE episodes SET played_at = datetime('now') WHERE show_slug = ? AND file_path = ?",
|
||||
[slug, filepath]
|
||||
)
|
||||
end
|
||||
|
||||
files = Dir.glob(show_dir.join("**/*.{mp3,m4a,ogg,flac}")).size
|
||||
played = played_db.get_first_row("SELECT COUNT(*) FROM played WHERE show=?", slug)[0]
|
||||
out[slug] = { total_files: files, played: played }
|
||||
def self.update_all
|
||||
subs_db = open_subs_db
|
||||
played_db = open_played_db
|
||||
shows = subs_db.query_all("SELECT slug, name FROM shows ORDER BY name")
|
||||
|
||||
shows.each do |show|
|
||||
slug = show["slug"]
|
||||
selected = select_unplayed_episode(slug, played_db)
|
||||
if selected.nil?
|
||||
log_info("#{slug}: no audio files found, skipping.")
|
||||
next
|
||||
end
|
||||
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
|
||||
|
||||
def close
|
||||
played_db&.close
|
||||
subs_db&.close
|
||||
subs_db.close
|
||||
played_db.close
|
||||
end
|
||||
|
||||
def self.json_summary
|
||||
subs_db = open_subs_db
|
||||
played_db = open_played_db
|
||||
shows = subs_db.query_all("SELECT slug FROM shows ORDER BY name")
|
||||
summary = {}
|
||||
shows.each do |show|
|
||||
slug = show["slug"]
|
||||
files = find_audio_files(File.join(PODCASTS_DIR, slug))
|
||||
played_count = played_db.get_first_value(
|
||||
"SELECT COUNT(*) FROM episodes WHERE show_slug = ? AND played_at IS NOT NULL", slug
|
||||
)
|
||||
summary[slug] = { "total_files" => files.size, "played_count" => played_count }
|
||||
end
|
||||
subs_db.close
|
||||
played_db.close
|
||||
puts JSON.pretty_generate(summary)
|
||||
end
|
||||
|
||||
def self.main
|
||||
options = {}
|
||||
OptionParser.new do |opts|
|
||||
opts.banner = "Usage: update_playlists.rb [options]"
|
||||
opts.on("--json", "Emit JSON summary and exit") { options[:json] = true }
|
||||
end.parse!
|
||||
|
||||
FileUtils.mkdir_p(STATE_DIR)
|
||||
FileUtils.mkdir_p(LOGS_DIR)
|
||||
FileUtils.mkdir_p(PLAYLISTS_DIR)
|
||||
|
||||
if options[:json]
|
||||
json_summary
|
||||
else
|
||||
update_all
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
require "set"
|
||||
|
||||
def main
|
||||
u = Radio::PlaylistUpdater.new
|
||||
if ARGV.include?("--json")
|
||||
u.dump_json
|
||||
else
|
||||
u.regenerate_all
|
||||
end
|
||||
ensure
|
||||
u&.close
|
||||
end
|
||||
|
||||
main
|
||||
RadioAutomation.main
|
||||
|
|
|
|||
Loading…
Reference in a new issue