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:
@@ -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)
|
||||
@@ -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 ==="
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 ""
|
||||
@@ -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])
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user