Initial commit: self-hosted personal radio station

Flask + React web UI with audio player, podcast queue, feed management,
episode browser, music library, schedule viewer, and log tail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 19:01:33 -07:00
co-authored by Claude Opus 4.6
commit 3d635b742c
53 changed files with 7023 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""
Manually enqueue a local audio file for immediate playback.
Usage:
enqueue_episode.py /path/to/episode.mp3
enqueue_episode.py /path/to/episode.mp3 "Episode Title"
"""
import os
import sys
from datetime import datetime
from pathlib import Path
BASE_DIR = Path(os.environ.get("LOCALRADIO_BASE", "/opt/localradio"))
QUEUE_DIR = BASE_DIR / "state" / "queue"
def enqueue(file_path: str, label: str | None = None) -> None:
src = Path(file_path).resolve()
if not src.exists():
print(f"Error: file not found: {src}", file=sys.stderr)
sys.exit(1)
if not src.suffix.lower() in (".mp3", ".ogg", ".opus", ".flac", ".m4a", ".wav"):
print(f"Warning: '{src.suffix}' may not be a supported audio format")
QUEUE_DIR.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = src.name.replace(" ", "_")
link_name = f"{timestamp}_manual_{safe_name}"
link_path = QUEUE_DIR / link_name
link_path.symlink_to(src)
print(f"Enqueued: {link_name}")
if label:
print(f" Label: {label}")
print(f" Target: {src}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <audio-file> [title]", file=sys.stderr)
sys.exit(1)
title = sys.argv[2] if len(sys.argv) > 2 else None
enqueue(sys.argv[1], title)
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# =============================================================================
# Local Radio Station — Health Check
# Reports status of all components
# =============================================================================
set -euo pipefail
INSTALL_DIR="${LOCALRADIO_BASE:-/opt/localradio}"
ICECAST_URL="http://localhost:8000/status.xsl"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
ok() { echo -e " ${GREEN}[OK]${NC} $1"; }
warn() { echo -e " ${YELLOW}[WARN]${NC} $1"; }
fail() { echo -e " ${RED}[FAIL]${NC} $1"; }
echo "=== Local Radio Health Check ==="
echo ""
# --- Service Status -----------------------------------------------------------
echo "Services:"
for svc in localradio-icecast localradio-stream localradio-poller; do
if systemctl is-active --quiet "$svc" 2>/dev/null; then
ok "$svc is running"
else
fail "$svc is NOT running"
fi
done
echo ""
# --- Icecast Connectivity -----------------------------------------------------
echo "Icecast:"
if curl -s --max-time 3 "$ICECAST_URL" > /dev/null 2>&1; then
ok "Icecast responding at $ICECAST_URL"
else
fail "Icecast not responding"
fi
# Check if stream mount is active
if curl -s --max-time 3 -o /dev/null -w "%{http_code}" "http://localhost:8000/stream" 2>/dev/null | grep -q "200"; then
ok "Stream mount /stream is active"
else
warn "Stream mount /stream not active (may need listeners)"
fi
echo ""
# --- Music Directories --------------------------------------------------------
echo "Music directories:"
for dir in morning day night weekend; do
full_path="$INSTALL_DIR/media/music/$dir"
if [ -d "$full_path" ]; then
count=$(find "$full_path" -type f \( -name "*.mp3" -o -name "*.flac" -o -name "*.ogg" -o -name "*.opus" -o -name "*.m4a" -o -name "*.wav" \) 2>/dev/null | wc -l)
if [ "$count" -gt 0 ]; then
ok "$dir: $count audio files"
else
warn "$dir: empty (no audio files)"
fi
else
fail "$dir: directory missing"
fi
done
echo ""
# --- Fallback -----------------------------------------------------------------
echo "Fallback:"
fallback_dir="$INSTALL_DIR/media/fallback"
if [ -d "$fallback_dir" ]; then
count=$(find "$fallback_dir" -type f \( -name "*.mp3" -o -name "*.flac" -o -name "*.ogg" -o -name "*.opus" -o -name "*.m4a" -o -name "*.wav" \) 2>/dev/null | wc -l)
if [ "$count" -gt 0 ]; then
ok "Fallback: $count audio files"
else
fail "Fallback: EMPTY — stream will go silent if all music dirs are empty!"
fi
else
fail "Fallback directory missing"
fi
echo ""
# --- Queue Status -------------------------------------------------------------
echo "Podcast queue:"
queue_dir="$INSTALL_DIR/state/queue"
if [ -d "$queue_dir" ]; then
queue_count=$(find "$queue_dir" -maxdepth 1 -type l -o -type f 2>/dev/null | wc -l)
if [ "$queue_count" -gt 0 ]; then
ok "$queue_count episode(s) in queue"
echo " Queue contents:"
ls -1 "$queue_dir" 2>/dev/null | head -10 | while read -r f; do
echo " - $f"
done
else
ok "Queue empty (music playing)"
fi
else
warn "Queue directory missing"
fi
echo ""
# --- Database -----------------------------------------------------------------
echo "Database:"
db_path="$INSTALL_DIR/state/radio.db"
if [ -f "$db_path" ]; then
ep_count=$(sqlite3 "$db_path" "SELECT COUNT(*) FROM episodes;" 2>/dev/null || echo "?")
queued=$(sqlite3 "$db_path" "SELECT COUNT(*) FROM queue WHERE played=0;" 2>/dev/null || echo "?")
feeds=$(sqlite3 "$db_path" "SELECT COUNT(*) FROM feed_state;" 2>/dev/null || echo "?")
ok "Database exists: $ep_count episodes tracked, $queued queued, $feeds feeds polled"
else
warn "Database not found (run init_db.py)"
fi
echo ""
# --- Feeds Config -------------------------------------------------------------
echo "Feeds config:"
feeds_file="$INSTALL_DIR/config/feeds.yaml"
if [ -f "$feeds_file" ]; then
ok "feeds.yaml exists"
else
fail "feeds.yaml not found"
fi
echo ""
# --- Recent Logs --------------------------------------------------------------
echo "Recent poller log:"
log_file="$INSTALL_DIR/logs/poller.log"
if [ -f "$log_file" ]; then
tail -5 "$log_file" | while read -r line; do
echo " $line"
done
else
warn "No poller log yet"
fi
echo ""
echo "=== Health check complete ==="
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Initialize the SQLite database for the local radio station."""
import sqlite3
import sys
from pathlib import Path
DEFAULT_DB_PATH = Path("/opt/localradio/state/radio.db")
def init_db(db_path: Path = DEFAULT_DB_PATH) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
cur = conn.cursor()
cur.executescript("""
CREATE TABLE IF NOT EXISTS episodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_name TEXT NOT NULL,
guid TEXT NOT NULL,
title TEXT,
url TEXT NOT NULL,
pub_date TEXT,
discovered TEXT NOT NULL DEFAULT (datetime('now')),
downloaded INTEGER NOT NULL DEFAULT 0,
file_path TEXT,
queued INTEGER NOT NULL DEFAULT 0,
played INTEGER NOT NULL DEFAULT 0,
UNIQUE(feed_name, guid)
);
CREATE INDEX IF NOT EXISTS idx_episodes_feed
ON episodes(feed_name);
CREATE INDEX IF NOT EXISTS idx_episodes_queued
ON episodes(queued);
CREATE TABLE IF NOT EXISTS queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
episode_id INTEGER NOT NULL REFERENCES episodes(id),
position INTEGER NOT NULL,
enqueued TEXT NOT NULL DEFAULT (datetime('now')),
played INTEGER NOT NULL DEFAULT 0,
UNIQUE(episode_id)
);
CREATE TABLE IF NOT EXISTS feed_state (
feed_name TEXT PRIMARY KEY,
last_poll TEXT,
last_etag TEXT,
last_modified TEXT
);
""")
conn.commit()
conn.close()
print(f"Database initialized at {db_path}")
if __name__ == "__main__":
path = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_DB_PATH
init_db(path)
+447
View File
@@ -0,0 +1,447 @@
#!/usr/bin/env python3
"""
RSS Feed Poller for Local Radio Station.
Polls configured RSS feeds, detects new episodes, downloads audio,
and enqueues them for Liquidsoap playback.
Designed to run as a long-lived systemd service.
"""
import hashlib
import logging
import os
import re
import shutil
import signal
import sqlite3
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
import feedparser
import requests
import yaml
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
BASE_DIR = Path(os.environ.get("LOCALRADIO_BASE", "/opt/localradio"))
CONFIG_DIR = BASE_DIR / "config"
STATE_DIR = BASE_DIR / "state"
QUEUE_DIR = STATE_DIR / "queue"
PODCAST_DIR = BASE_DIR / "media" / "podcasts"
DB_PATH = STATE_DIR / "radio.db"
FEEDS_CONFIG = CONFIG_DIR / "feeds.yaml"
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "300")) # seconds
DOWNLOAD_TIMEOUT = int(os.environ.get("DOWNLOAD_TIMEOUT", "600")) # seconds
USER_AGENT = "LocalRadio/1.0 (Personal Podcast Poller)"
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
LOG_DIR = BASE_DIR / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(LOG_DIR / "poller.log"),
],
)
log = logging.getLogger("poller")
# ---------------------------------------------------------------------------
# Graceful shutdown
# ---------------------------------------------------------------------------
_shutdown = False
def _handle_signal(signum, frame):
global _shutdown
log.info("Received signal %s, shutting down gracefully...", signum)
_shutdown = True
signal.signal(signal.SIGTERM, _handle_signal)
signal.signal(signal.SIGINT, _handle_signal)
# ---------------------------------------------------------------------------
# Database helpers
# ---------------------------------------------------------------------------
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def episode_exists(conn: sqlite3.Connection, feed_name: str, guid: str) -> bool:
row = conn.execute(
"SELECT 1 FROM episodes WHERE feed_name=? AND guid=?", (feed_name, guid)
).fetchone()
return row is not None
def insert_episode(
conn: sqlite3.Connection,
feed_name: str,
guid: str,
title: str,
url: str,
pub_date: str | None,
) -> int:
cur = conn.execute(
"""INSERT INTO episodes (feed_name, guid, title, url, pub_date)
VALUES (?, ?, ?, ?, ?)""",
(feed_name, guid, title, url, pub_date),
)
conn.commit()
return cur.lastrowid
def mark_downloaded(conn: sqlite3.Connection, episode_id: int, file_path: str) -> None:
conn.execute(
"UPDATE episodes SET downloaded=1, file_path=? WHERE id=?",
(file_path, episode_id),
)
conn.commit()
def enqueue_episode(conn: sqlite3.Connection, episode_id: int) -> None:
# Position = next available integer
row = conn.execute("SELECT COALESCE(MAX(position), 0) + 1 FROM queue").fetchone()
next_pos = row[0]
conn.execute(
"INSERT OR IGNORE INTO queue (episode_id, position) VALUES (?, ?)",
(episode_id, next_pos),
)
conn.execute("UPDATE episodes SET queued=1 WHERE id=?", (episode_id,))
conn.commit()
def get_feed_state(conn: sqlite3.Connection, feed_name: str) -> dict | None:
row = conn.execute(
"SELECT * FROM feed_state WHERE feed_name=?", (feed_name,)
).fetchone()
return dict(row) if row else None
def update_feed_state(
conn: sqlite3.Connection,
feed_name: str,
etag: str | None = None,
modified: str | None = None,
) -> None:
conn.execute(
"""INSERT INTO feed_state (feed_name, last_poll, last_etag, last_modified)
VALUES (?, datetime('now'), ?, ?)
ON CONFLICT(feed_name) DO UPDATE SET
last_poll=datetime('now'),
last_etag=COALESCE(?, last_etag),
last_modified=COALESCE(?, last_modified)""",
(feed_name, etag, modified, etag, modified),
)
conn.commit()
# ---------------------------------------------------------------------------
# Feed parsing
# ---------------------------------------------------------------------------
def load_feeds() -> list[dict]:
with open(FEEDS_CONFIG) as f:
data = yaml.safe_load(f)
feeds = data.get("feeds", [])
return [f for f in feeds if f.get("enabled", True)]
def extract_audio_url(entry) -> str | None:
"""Extract the audio enclosure URL from a feed entry."""
# Check enclosures first
for enc in getattr(entry, "enclosures", []):
etype = enc.get("type", "")
if etype.startswith("audio/") or enc.get("url", "").endswith(
(".mp3", ".m4a", ".ogg", ".opus", ".wav", ".flac")
):
return enc.get("url") or enc.get("href")
# Check media content
for media in getattr(entry, "media_content", []):
mtype = media.get("type", "")
if mtype.startswith("audio/"):
return media.get("url")
# Check links
for link in getattr(entry, "links", []):
if link.get("type", "").startswith("audio/"):
return link.get("href")
return None
def extract_guid(entry) -> str:
"""Get a stable unique identifier for an entry."""
if hasattr(entry, "id") and entry.id:
return entry.id
url = extract_audio_url(entry)
if url:
return hashlib.sha256(url.encode()).hexdigest()
title = getattr(entry, "title", "")
return hashlib.sha256(title.encode()).hexdigest()
def extract_pub_date(entry) -> str | None:
if hasattr(entry, "published_parsed") and entry.published_parsed:
try:
return datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).isoformat()
except Exception:
pass
return getattr(entry, "published", None)
# ---------------------------------------------------------------------------
# Downloading
# ---------------------------------------------------------------------------
def sanitize_filename(name: str) -> str:
name = re.sub(r'[<>:"/\\|?*]', "_", name)
name = re.sub(r"\s+", "_", name)
return name[:200]
def download_episode(url: str, feed_name: str, title: str) -> Path | None:
"""Download an audio file. Returns the local path or None on failure."""
feed_dir = PODCAST_DIR / sanitize_filename(feed_name)
feed_dir.mkdir(parents=True, exist_ok=True)
# Determine filename from URL or title
parsed = urlparse(url)
url_filename = Path(parsed.path).name
if not url_filename or len(url_filename) < 3:
url_filename = sanitize_filename(title) + ".mp3"
dest = feed_dir / sanitize_filename(url_filename)
if dest.exists():
log.info("File already exists: %s", dest)
return dest
tmp = dest.with_suffix(dest.suffix + ".part")
try:
log.info("Downloading: %s", url)
with requests.get(
url,
stream=True,
timeout=DOWNLOAD_TIMEOUT,
headers={"User-Agent": USER_AGENT},
) as resp:
resp.raise_for_status()
with open(tmp, "wb") as f:
for chunk in resp.iter_content(chunk_size=65536):
if _shutdown:
log.warning("Shutdown during download, aborting")
tmp.unlink(missing_ok=True)
return None
f.write(chunk)
tmp.rename(dest)
log.info("Downloaded: %s (%.1f MB)", dest, dest.stat().st_size / 1048576)
return dest
except Exception:
log.exception("Failed to download %s", url)
tmp.unlink(missing_ok=True)
return None
# ---------------------------------------------------------------------------
# Queue management
# ---------------------------------------------------------------------------
def link_to_queue(file_path: Path, episode_id: int) -> None:
"""Create a symlink in the queue directory for Liquidsoap to pick up.
Filename is prefixed with a timestamp to ensure FIFO ordering when
Liquidsoap reads the directory in alphabetical order.
"""
QUEUE_DIR.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
link_name = f"{timestamp}_{episode_id:06d}_{file_path.name}"
link_path = QUEUE_DIR / link_name
try:
link_path.symlink_to(file_path.resolve())
log.info("Queued (symlink): %s -> %s", link_name, file_path)
except (OSError, FileExistsError):
# Symlinks may fail on Windows without admin; fall back to hard link
try:
link_path.hardlink_to(file_path.resolve())
log.info("Queued (hardlink): %s -> %s", link_name, file_path)
except (OSError, FileExistsError):
# Last resort: copy the file
shutil.copy2(str(file_path), str(link_path))
log.info("Queued (copy): %s -> %s", link_name, file_path)
def cleanup_played_queue_files(conn: sqlite3.Connection) -> None:
"""Remove queue symlinks for files that no longer exist (already played).
Liquidsoap in 'normal' mode plays files and moves on. We detect played
files by checking if the symlink target is gone or if the file was consumed.
This is a simple approach: remove broken symlinks.
"""
if not QUEUE_DIR.exists():
return
for item in sorted(QUEUE_DIR.iterdir()):
if item.is_symlink() and not item.resolve().exists():
log.info("Removing broken queue link: %s", item.name)
item.unlink()
# ---------------------------------------------------------------------------
# Main poll cycle
# ---------------------------------------------------------------------------
def poll_feed(conn: sqlite3.Connection, feed_cfg: dict) -> int:
"""Poll a single feed. Returns number of new episodes found."""
name = feed_cfg["name"]
url = feed_cfg["url"]
new_count = 0
log.info("Polling feed: %s", name)
state = get_feed_state(conn, name)
kwargs = {}
if state:
if state.get("last_etag"):
kwargs["etag"] = state["last_etag"]
if state.get("last_modified"):
kwargs["modified"] = state["last_modified"]
try:
feed = feedparser.parse(url, agent=USER_AGENT, **kwargs)
except Exception:
log.exception("Failed to parse feed: %s", name)
return 0
if feed.bozo and not feed.entries:
log.warning("Feed error for %s: %s", name, feed.bozo_exception)
return 0
# Update feed state
etag = getattr(feed, "etag", None)
modified = getattr(feed, "modified", None)
update_feed_state(conn, name, etag, modified)
if feed.status == 304:
log.info("Feed %s: not modified", name)
return 0
for entry in feed.entries:
guid = extract_guid(entry)
if episode_exists(conn, name, guid):
continue
audio_url = extract_audio_url(entry)
if not audio_url:
log.debug("No audio URL in entry: %s", getattr(entry, "title", "unknown"))
continue
title = getattr(entry, "title", "Untitled")
pub_date = extract_pub_date(entry)
episode_id = insert_episode(conn, name, guid, title, audio_url, pub_date)
log.info("New episode: [%s] %s", name, title)
# Download
file_path = download_episode(audio_url, name, title)
if _shutdown:
return new_count
if file_path:
mark_downloaded(conn, episode_id, str(file_path))
enqueue_episode(conn, episode_id)
link_to_queue(file_path, episode_id)
new_count += 1
else:
log.error("Skipping episode (download failed): %s", title)
return new_count
def poll_all_feeds() -> None:
feeds = load_feeds()
if not feeds:
log.warning("No enabled feeds configured in %s", FEEDS_CONFIG)
return
conn = get_db()
try:
total_new = 0
for feed_cfg in feeds:
if _shutdown:
break
try:
total_new += poll_feed(conn, feed_cfg)
except Exception:
log.exception("Error processing feed: %s", feed_cfg.get("name"))
cleanup_played_queue_files(conn)
if total_new > 0:
log.info("Poll complete: %d new episode(s) enqueued", total_new)
else:
log.info("Poll complete: no new episodes")
finally:
conn.close()
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
log.info("Local Radio RSS Poller starting (interval=%ds)", POLL_INTERVAL)
log.info("Config: %s", FEEDS_CONFIG)
log.info("Database: %s", DB_PATH)
log.info("Queue dir: %s", QUEUE_DIR)
# Ensure directories exist
QUEUE_DIR.mkdir(parents=True, exist_ok=True)
PODCAST_DIR.mkdir(parents=True, exist_ok=True)
# Ensure DB is initialized
from init_db import init_db
init_db(DB_PATH)
while not _shutdown:
try:
poll_all_feeds()
except Exception:
log.exception("Unexpected error in poll cycle")
# Sleep in small increments so we can respond to signals
for _ in range(POLL_INTERVAL):
if _shutdown:
break
time.sleep(1)
log.info("Poller shut down cleanly")
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Show the current podcast queue and episode history."""
import os
import sqlite3
import sys
from pathlib import Path
BASE_DIR = Path(os.environ.get("LOCALRADIO_BASE", "/opt/localradio"))
DB_PATH = BASE_DIR / "state" / "radio.db"
QUEUE_DIR = BASE_DIR / "state" / "queue"
def show_queue():
if not DB_PATH.exists():
print("Database not found. Run init_db.py first.")
sys.exit(1)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
# Current queue
rows = conn.execute("""
SELECT q.position, e.feed_name, e.title, e.pub_date, e.file_path, q.enqueued
FROM queue q
JOIN episodes e ON e.id = q.episode_id
WHERE q.played = 0
ORDER BY q.position
""").fetchall()
print("=== Podcast Queue ===")
if rows:
for row in rows:
print(f" [{row['position']:3d}] {row['feed_name']}: {row['title']}")
print(f" Published: {row['pub_date'] or 'unknown'}")
print(f" Enqueued: {row['enqueued']}")
else:
print(" (empty — music is playing)")
# Queue directory
print(f"\n=== Queue Directory ({QUEUE_DIR}) ===")
if QUEUE_DIR.exists():
files = sorted(QUEUE_DIR.iterdir())
if files:
for f in files:
target = f" -> {os.readlink(f)}" if f.is_symlink() else ""
print(f" {f.name}{target}")
else:
print(" (empty)")
else:
print(" (directory missing)")
# Recent episodes
recent = conn.execute("""
SELECT feed_name, title, pub_date, downloaded, queued, played, discovered
FROM episodes
ORDER BY discovered DESC
LIMIT 15
""").fetchall()
print("\n=== Recent Episodes (last 15) ===")
for row in recent:
status_flags = []
if row["downloaded"]:
status_flags.append("DL")
if row["queued"]:
status_flags.append("Q")
if row["played"]:
status_flags.append("P")
status = ",".join(status_flags) or "-"
print(f" [{status:6s}] {row['feed_name']}: {row['title']}")
# Feed state
feeds = conn.execute("SELECT * FROM feed_state ORDER BY feed_name").fetchall()
print("\n=== Feed State ===")
for f in feeds:
print(f" {f['feed_name']}: last polled {f['last_poll'] or 'never'}")
conn.close()
if __name__ == "__main__":
show_queue()
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# =============================================================================
# Local Radio Station — Initial Setup Script
# Run as root on Debian 13
# =============================================================================
set -euo pipefail
INSTALL_DIR="/opt/localradio"
RADIO_USER="localradio"
RADIO_GROUP="localradio"
echo "=== Local Radio Station Setup ==="
# --- Install system packages --------------------------------------------------
echo "[1/7] Installing system packages..."
apt-get update
apt-get install -y \
icecast2 \
liquidsoap \
python3 \
python3-venv \
python3-pip \
ffmpeg \
sqlite3
# --- Create system user ------------------------------------------------------
echo "[2/7] Creating system user..."
if ! id "$RADIO_USER" &>/dev/null; then
useradd --system --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin "$RADIO_USER"
echo " Created user: $RADIO_USER"
else
echo " User $RADIO_USER already exists"
fi
# --- Create directory structure -----------------------------------------------
echo "[3/7] Creating directory structure..."
mkdir -p "$INSTALL_DIR"/{config,scripts,state/queue,media/music/{morning,day,night,weekend},media/podcasts,media/fallback,logs}
# --- Copy project files -------------------------------------------------------
echo "[4/7] Copying project files..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cp "$SCRIPT_DIR/config/station.liq" "$INSTALL_DIR/config/"
cp "$SCRIPT_DIR/config/icecast.xml" "$INSTALL_DIR/config/"
cp "$SCRIPT_DIR/config/station.yaml" "$INSTALL_DIR/config/"
cp "$SCRIPT_DIR/config/localradio.env" "$INSTALL_DIR/config/"
# Only copy feeds.yaml if it doesn't already exist (preserve user edits)
if [ ! -f "$INSTALL_DIR/config/feeds.yaml" ]; then
cp "$SCRIPT_DIR/config/feeds.yaml" "$INSTALL_DIR/config/"
fi
cp "$SCRIPT_DIR/scripts/"*.py "$INSTALL_DIR/scripts/"
cp "$SCRIPT_DIR/scripts/"*.sh "$INSTALL_DIR/scripts/" 2>/dev/null || true
cp "$SCRIPT_DIR/requirements.txt" "$INSTALL_DIR/"
# Copy web UI
cp -r "$SCRIPT_DIR/web" "$INSTALL_DIR/"
# --- Set up Python virtual environment ----------------------------------------
echo "[5/7] Setting up Python virtual environment..."
python3 -m venv "$INSTALL_DIR/venv"
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt"
# --- Build frontend -----------------------------------------------------------
echo "[5b/7] Building frontend..."
if command -v npm &>/dev/null; then
cd "$INSTALL_DIR/web/frontend"
npm install
npm run build
cd -
echo " Frontend built successfully"
else
echo " WARNING: npm not found. Install Node.js and run:"
echo " cd $INSTALL_DIR/web/frontend && npm install && npm run build"
fi
# --- Initialize database ------------------------------------------------------
echo "[6/7] Initializing database..."
PYTHONPATH="$INSTALL_DIR/scripts" "$INSTALL_DIR/venv/bin/python" \
"$INSTALL_DIR/scripts/init_db.py" "$INSTALL_DIR/state/radio.db"
# --- Set ownership ------------------------------------------------------------
echo "[7/7] Setting file ownership..."
chown -R "$RADIO_USER:$RADIO_GROUP" "$INSTALL_DIR"
# Make scripts executable
chmod +x "$INSTALL_DIR/scripts/"*.sh 2>/dev/null || true
chmod +x "$INSTALL_DIR/scripts/"*.py
# --- Install systemd services -------------------------------------------------
echo ""
echo "=== Installing systemd services ==="
cp "$SCRIPT_DIR/systemd/"*.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable localradio-icecast localradio-stream localradio-poller localradio-web
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Next steps:"
echo " 1. Add music files to $INSTALL_DIR/media/music/{morning,day,night,weekend}/"
echo " 2. Add at least one audio file to $INSTALL_DIR/media/fallback/"
echo " 3. Edit $INSTALL_DIR/config/feeds.yaml to add your podcast RSS feeds"
echo " 4. Review $INSTALL_DIR/config/icecast.xml passwords"
echo " 5. Start services:"
echo " systemctl start localradio-icecast"
echo " systemctl start localradio-stream"
echo " systemctl start localradio-poller"
echo " systemctl start localradio-web"
echo " 6. Listen at: http://localhost:8000/stream"
echo " 7. Web dashboard: http://localhost:5000"
echo ""
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Test downloading from a specific RSS feed without affecting the database."""
import sys
import feedparser
def test_feed(url: str) -> None:
print(f"Fetching: {url}\n")
feed = feedparser.parse(url)
if feed.bozo and not feed.entries:
print(f"ERROR: Feed parse error: {feed.bozo_exception}")
sys.exit(1)
print(f"Feed title: {feed.feed.get('title', 'N/A')}")
print(f"Entries found: {len(feed.entries)}")
print()
for i, entry in enumerate(feed.entries[:5]):
print(f"--- Entry {i + 1} ---")
print(f" Title: {getattr(entry, 'title', 'N/A')}")
print(f" Published: {getattr(entry, 'published', 'N/A')}")
print(f" ID: {getattr(entry, 'id', 'N/A')}")
# Find audio
audio_url = None
for enc in getattr(entry, "enclosures", []):
etype = enc.get("type", "")
if etype.startswith("audio/"):
audio_url = enc.get("url") or enc.get("href")
print(f" Audio: {audio_url}")
print(f" Type: {etype}")
print(f" Length: {enc.get('length', 'N/A')} bytes")
break
if not audio_url:
for media in getattr(entry, "media_content", []):
if media.get("type", "").startswith("audio/"):
audio_url = media.get("url")
print(f" Audio: {audio_url}")
break
if not audio_url:
print(" Audio: (none found)")
print()
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <rss-feed-url>")
print(f"Example: {sys.argv[0]} https://feeds.example.com/podcast.xml")
sys.exit(1)
test_feed(sys.argv[1])
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Validate all configuration files for the local radio station."""
import os
import sys
from pathlib import Path
import yaml
BASE_DIR = Path(os.environ.get("LOCALRADIO_BASE", "/opt/localradio"))
errors = []
warnings = []
def err(msg):
errors.append(msg)
print(f" ERROR: {msg}")
def warn(msg):
warnings.append(msg)
print(f" WARN: {msg}")
def ok(msg):
print(f" OK: {msg}")
def validate_feeds():
print("=== Validating feeds.yaml ===")
path = BASE_DIR / "config" / "feeds.yaml"
if not path.exists():
err(f"Missing: {path}")
return
with open(path) as f:
data = yaml.safe_load(f)
if not data or "feeds" not in data:
err("feeds.yaml must have a 'feeds' key")
return
feeds = data["feeds"]
enabled_count = 0
for i, feed in enumerate(feeds):
if not feed.get("name"):
err(f"Feed [{i}]: missing 'name'")
if not feed.get("url"):
err(f"Feed [{i}]: missing 'url'")
elif not feed["url"].startswith(("http://", "https://")):
err(f"Feed '{feed.get('name', i)}': URL must start with http:// or https://")
if feed.get("enabled", True):
enabled_count += 1
if enabled_count == 0:
warn("No feeds are enabled")
else:
ok(f"{enabled_count} feed(s) enabled")
def validate_station():
print("\n=== Validating station.yaml ===")
path = BASE_DIR / "config" / "station.yaml"
if not path.exists():
err(f"Missing: {path}")
return
with open(path) as f:
data = yaml.safe_load(f)
if not data.get("schedule"):
err("station.yaml must have a 'schedule' section")
return
for block in data["schedule"]:
name = block.get("name", "unnamed")
if not block.get("folder"):
err(f"Schedule '{name}': missing 'folder'")
if not block.get("days"):
err(f"Schedule '{name}': missing 'days'")
if not block.get("start") or not block.get("end"):
err(f"Schedule '{name}': missing 'start' or 'end'")
ok(f"{len(data['schedule'])} schedule block(s) defined")
fmt = data.get("audio", {}).get("format", "mp3")
bitrate = data.get("audio", {}).get("bitrate", 320)
ok(f"Audio: {fmt} @ {bitrate} kbps")
def validate_directories():
print("\n=== Validating directories ===")
required = [
"config",
"scripts",
"state",
"state/queue",
"media/music/morning",
"media/music/day",
"media/music/night",
"media/music/weekend",
"media/podcasts",
"media/fallback",
"logs",
]
for d in required:
full = BASE_DIR / d
if full.exists():
ok(f"{d}/")
else:
err(f"Missing directory: {d}/")
# Check fallback has content
fallback = BASE_DIR / "media" / "fallback"
if fallback.exists():
audio_exts = {".mp3", ".flac", ".ogg", ".opus", ".m4a", ".wav"}
files = [f for f in fallback.iterdir() if f.suffix.lower() in audio_exts]
if not files:
warn("media/fallback/ has no audio files — stream may go silent!")
else:
ok(f"media/fallback/ has {len(files)} audio file(s)")
def validate_icecast():
print("\n=== Validating icecast.xml ===")
path = BASE_DIR / "config" / "icecast.xml"
if not path.exists():
err(f"Missing: {path}")
return
content = path.read_text()
if "localradio_source" in content:
warn("icecast.xml still uses default source password — change it for security")
if "localradio_admin" in content:
warn("icecast.xml still uses default admin password — change it for security")
ok("icecast.xml exists and is parseable")
def validate_liquidsoap():
print("\n=== Validating station.liq ===")
path = BASE_DIR / "config" / "station.liq"
if not path.exists():
err(f"Missing: {path}")
return
ok("station.liq exists")
def main():
print(f"Validating configuration at: {BASE_DIR}\n")
validate_feeds()
validate_station()
validate_directories()
validate_icecast()
validate_liquidsoap()
print("\n" + "=" * 50)
if errors:
print(f"RESULT: {len(errors)} error(s), {len(warnings)} warning(s)")
sys.exit(1)
elif warnings:
print(f"RESULT: OK with {len(warnings)} warning(s)")
else:
print("RESULT: All checks passed")
if __name__ == "__main__":
main()