From 3d635b742c74476a6044b8399bfab480238165c2 Mon Sep 17 00:00:00 2001 From: profit Date: Fri, 13 Mar 2026 19:01:33 -0700 Subject: [PATCH] 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 --- .gitignore | 29 + README.md | 274 ++ config/feeds.yaml | 11 + config/icecast.xml | 58 + config/localradio.env | 10 + config/station.liq | 156 + config/station.yaml | 67 + requirements.txt | 4 + run_web.py | 11 + scripts/enqueue_episode.py | 46 + scripts/healthcheck.sh | 142 + scripts/init_db.py | 61 + scripts/poll_feeds.py | 447 +++ scripts/queue_status.py | 83 + scripts/setup.sh | 114 + scripts/test_feed.py | 56 + scripts/validate_config.py | 167 + systemd/localradio-icecast.service | 17 + systemd/localradio-poller.service | 20 + systemd/localradio-stream.service | 21 + systemd/localradio-web.service | 20 + web/__init__.py | 0 web/api/__init__.py | 13 + web/api/episodes.py | 63 + web/api/feeds.py | 127 + web/api/logs.py | 48 + web/api/music.py | 108 + web/api/queue.py | 120 + web/api/schedule.py | 50 + web/api/status.py | 88 + web/api/stream.py | 179 ++ web/app.py | 45 + web/config.py | 23 + web/db.py | 23 + web/frontend/index.html | 13 + web/frontend/package-lock.json | 2754 +++++++++++++++++ web/frontend/package.json | 23 + web/frontend/postcss.config.js | 11 + web/frontend/src/App.jsx | 28 + web/frontend/src/api.js | 54 + web/frontend/src/components/AudioPlayer.jsx | 216 ++ web/frontend/src/components/Dashboard.jsx | 123 + web/frontend/src/components/EpisodesPanel.jsx | 155 + web/frontend/src/components/FeedsPanel.jsx | 180 ++ web/frontend/src/components/Layout.jsx | 85 + web/frontend/src/components/LogsPanel.jsx | 107 + web/frontend/src/components/MusicPanel.jsx | 172 + web/frontend/src/components/QueuePanel.jsx | 128 + web/frontend/src/components/SchedulePanel.jsx | 117 + web/frontend/src/main.jsx | 10 + web/frontend/src/styles/index.css | 95 + web/frontend/tailwind.config.js | 30 + web/frontend/vite.config.js | 21 + 53 files changed, 7023 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/feeds.yaml create mode 100644 config/icecast.xml create mode 100644 config/localradio.env create mode 100644 config/station.liq create mode 100644 config/station.yaml create mode 100644 requirements.txt create mode 100644 run_web.py create mode 100644 scripts/enqueue_episode.py create mode 100644 scripts/healthcheck.sh create mode 100644 scripts/init_db.py create mode 100644 scripts/poll_feeds.py create mode 100644 scripts/queue_status.py create mode 100644 scripts/setup.sh create mode 100644 scripts/test_feed.py create mode 100644 scripts/validate_config.py create mode 100644 systemd/localradio-icecast.service create mode 100644 systemd/localradio-poller.service create mode 100644 systemd/localradio-stream.service create mode 100644 systemd/localradio-web.service create mode 100644 web/__init__.py create mode 100644 web/api/__init__.py create mode 100644 web/api/episodes.py create mode 100644 web/api/feeds.py create mode 100644 web/api/logs.py create mode 100644 web/api/music.py create mode 100644 web/api/queue.py create mode 100644 web/api/schedule.py create mode 100644 web/api/status.py create mode 100644 web/api/stream.py create mode 100644 web/app.py create mode 100644 web/config.py create mode 100644 web/db.py create mode 100644 web/frontend/index.html create mode 100644 web/frontend/package-lock.json create mode 100644 web/frontend/package.json create mode 100644 web/frontend/postcss.config.js create mode 100644 web/frontend/src/App.jsx create mode 100644 web/frontend/src/api.js create mode 100644 web/frontend/src/components/AudioPlayer.jsx create mode 100644 web/frontend/src/components/Dashboard.jsx create mode 100644 web/frontend/src/components/EpisodesPanel.jsx create mode 100644 web/frontend/src/components/FeedsPanel.jsx create mode 100644 web/frontend/src/components/Layout.jsx create mode 100644 web/frontend/src/components/LogsPanel.jsx create mode 100644 web/frontend/src/components/MusicPanel.jsx create mode 100644 web/frontend/src/components/QueuePanel.jsx create mode 100644 web/frontend/src/components/SchedulePanel.jsx create mode 100644 web/frontend/src/main.jsx create mode 100644 web/frontend/src/styles/index.css create mode 100644 web/frontend/tailwind.config.js create mode 100644 web/frontend/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58d0695 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Runtime data +state/ +logs/ +media/ + +# Portable toolchains +.local/ + +# Python +__pycache__/ +*.pyc +*.pyo +venv/ +.venv/ + +# Node / Frontend build +web/frontend/node_modules/ +web/frontend/dist/ + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ + +# Claude +.claude/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d351fd1 --- /dev/null +++ b/README.md @@ -0,0 +1,274 @@ +# Local Radio Station + +A self-hosted personal radio station that streams music continuously with scheduled playlists, and automatically interrupts for new podcast episodes detected via RSS feeds. + +## Architecture + +``` +RSS Feeds ──> [Python Poller] ──> SQLite + Download ──> queue/ directory + │ +Liquidsoap reads: podcast queue > scheduled music > fallback + │ + Icecast Server + │ + VLC / Browser / Mobile +``` + +**Components:** +- **Icecast2** — HTTP streaming server, serves audio to listeners +- **Liquidsoap** — Audio engine handling source priority, scheduling, and encoding +- **Python Poller** — Long-running service that polls RSS feeds, downloads episodes, and manages the queue +- **SQLite** — Persistent state for episode tracking and deduplication +- **systemd** — Process supervision for all services + +## Quick Start + +### 1. Install (as root on Debian 13) + +```bash +git clone /tmp/localradio-src +cd /tmp/localradio-src +bash scripts/setup.sh +``` + +### 2. Add Content + +```bash +# Add music to schedule folders +cp ~/music/chill/*.mp3 /opt/localradio/media/music/morning/ +cp ~/music/upbeat/*.mp3 /opt/localradio/media/music/day/ +cp ~/music/ambient/*.mp3 /opt/localradio/media/music/night/ +cp ~/music/mix/*.mp3 /opt/localradio/media/music/weekend/ + +# IMPORTANT: Add at least one file to fallback (prevents silence) +cp ~/music/safe/*.mp3 /opt/localradio/media/fallback/ +``` + +### 3. Configure Feeds + +Edit `/opt/localradio/config/feeds.yaml`: + +```yaml +feeds: + - name: "My Podcast" + url: "https://feeds.example.com/mypodcast.xml" + enabled: true + priority: 10 + max_episodes: 30 +``` + +### 4. Start Services + +```bash +systemctl start localradio-icecast +systemctl start localradio-stream +systemctl start localradio-poller +``` + +### 5. Listen + +- **Browser:** `http://localhost:8000/stream` +- **VLC:** File > Open Network Stream > `http://localhost:8000/stream` +- **mpv:** `mpv http://localhost:8000/stream` +- **Mobile:** Use any app that supports HTTP streams (VLC, foobar2000, etc.) with the URL above + +If listening from another device on your LAN, replace `localhost` with the server's IP and ensure the `bind-address` in `icecast.xml` is changed from `127.0.0.1` to `0.0.0.0`. + +--- + +## Directory Structure + +``` +/opt/localradio/ +├── config/ +│ ├── icecast.xml # Icecast server config +│ ├── station.liq # Liquidsoap streaming config +│ ├── station.yaml # Schedule and audio settings +│ ├── feeds.yaml # RSS feed definitions +│ └── localradio.env # Environment overrides +├── scripts/ +│ ├── poll_feeds.py # RSS poller daemon +│ ├── init_db.py # Database initialization +│ ├── enqueue_episode.py # Manually enqueue audio files +│ ├── queue_status.py # View queue and episode state +│ ├── validate_config.py # Validate all configs +│ ├── test_feed.py # Test an RSS feed URL +│ ├── healthcheck.sh # System health check +│ └── setup.sh # Initial installation +├── state/ +│ ├── radio.db # SQLite database +│ └── queue/ # Symlinks for Liquidsoap queue +├── media/ +│ ├── music/ +│ │ ├── morning/ # Weekday 06:00-12:00 +│ │ ├── day/ # Weekday 12:00-18:00 +│ │ ├── night/ # Weekday 18:00-06:00 +│ │ └── weekend/ # Saturday & Sunday +│ ├── podcasts/ # Downloaded podcast episodes +│ └── fallback/ # Emergency fallback audio +├── logs/ +├── systemd/ # Service unit files +├── venv/ # Python virtual environment +└── requirements.txt +``` + +--- + +## Configuration Guide + +### Music Schedule (`station.yaml`) + +The `schedule` section defines which music folder plays at what time: + +```yaml +schedule: + - name: "Weekday Morning" + days: [1, 2, 3, 4, 5] # 1=Monday ... 7=Sunday + start: "06:00" + end: "12:00" + folder: "morning" # Subfolder of media/music/ +``` + +To change the schedule, edit `station.yaml` and the corresponding `switch()` block in `station.liq`. The YAML file documents intent; the `.liq` file is what Liquidsoap actually reads. + +### Adding/Removing Feeds (`feeds.yaml`) + +Add a feed: +```yaml +- name: "New Show" + url: "https://example.com/feed.xml" + enabled: true + priority: 10 + max_episodes: 20 +``` + +Remove a feed: delete the entry or set `enabled: false`. + +Test a feed before adding: +```bash +/opt/localradio/venv/bin/python /opt/localradio/scripts/test_feed.py "https://example.com/feed.xml" +``` + +Changes take effect on the next poll cycle (no restart needed). + +### Changing Bitrate/Format + +In `station.liq`, find the `output.icecast()` block: + +**MP3 320 kbps (default):** +```liquidsoap +output.icecast( + %mp3(bitrate=320, samplerate=44100, stereo=true), + ... +) +``` + +**Opus (high quality):** +```liquidsoap +output.icecast( + %opus(bitrate=192, samplerate=48000, channels=2), + ... +) +``` + +Restart the stream after changes: `systemctl restart localradio-stream` + +### Poll Interval + +Set in `/opt/localradio/config/localradio.env`: +```bash +POLL_INTERVAL=300 # seconds (default: 5 minutes) +``` + +Restart the poller: `systemctl restart localradio-poller` + +--- + +## How the Queue and Dedup Logic Works + +### Episode Discovery +1. The poller reads `feeds.yaml` for enabled feeds +2. Each feed is parsed via `feedparser` +3. For each entry, a GUID is extracted (from RSS ``, or hashed from the audio URL) +4. The GUID + feed name are checked against SQLite — if the pair exists, it's skipped + +### Download and Enqueue +1. New episodes are downloaded to `media/podcasts//` +2. Partial downloads use a `.part` suffix and are renamed on completion +3. A symlink is created in `state/queue/` with a timestamp prefix for FIFO ordering +4. The episode is recorded in both the `episodes` and `queue` tables + +### Playback +1. Liquidsoap watches `state/queue/` as a playlist in `normal` (sequential) mode +2. When files appear, Liquidsoap plays them with priority over music +3. `track_sensitive=true` ensures music finishes its current track before podcast takeover +4. After the queue is empty, music resumes from the current schedule block + +### Restart Safety +- SQLite WAL mode prevents corruption from unexpected shutdowns +- The poller checks for existing GUIDs on startup, so restarting never causes re-downloads +- Queue symlinks persist across restarts +- Broken symlinks (played episodes) are cleaned up each poll cycle + +--- + +## Troubleshooting + +### Stream is silent +1. Check fallback: `ls /opt/localradio/media/fallback/` — must have at least one audio file +2. Check music dirs: `ls /opt/localradio/media/music/morning/` (etc.) +3. Check Liquidsoap logs: `journalctl -u localradio-stream -f` +4. Run healthcheck: `bash /opt/localradio/scripts/healthcheck.sh` + +### Podcasts not downloading +1. Check poller logs: `journalctl -u localradio-poller -f` or `cat /opt/localradio/logs/poller.log` +2. Test the feed: `/opt/localradio/venv/bin/python /opt/localradio/scripts/test_feed.py ` +3. Check the database: `/opt/localradio/venv/bin/python /opt/localradio/scripts/queue_status.py` +4. Verify feeds are `enabled: true` in `feeds.yaml` + +### Icecast won't start +1. Check if port 8000 is in use: `ss -tlnp | grep 8000` +2. Check logs: `journalctl -u localradio-icecast -f` +3. Validate XML: `xmllint --noout /opt/localradio/config/icecast.xml` + +### Liquidsoap errors +1. Test config syntax: `liquidsoap --check /opt/localradio/config/station.liq` +2. Check logs: `cat /opt/localradio/logs/liquidsoap.log` +3. Common issue: empty playlist directories — add files or ensure fallback has content + +### Reset everything +```bash +systemctl stop localradio-poller localradio-stream localradio-icecast +rm /opt/localradio/state/radio.db +rm -f /opt/localradio/state/queue/* +/opt/localradio/venv/bin/python /opt/localradio/scripts/init_db.py +systemctl start localradio-icecast localradio-stream localradio-poller +``` + +--- + +## Service Management + +```bash +# Start all +systemctl start localradio-icecast localradio-stream localradio-poller + +# Stop all +systemctl stop localradio-poller localradio-stream localradio-icecast + +# View logs +journalctl -u localradio-stream -f +journalctl -u localradio-poller -f + +# Restart after config change +systemctl restart localradio-stream # for station.liq changes +systemctl restart localradio-poller # for feeds.yaml or env changes +``` + +## Manual Episode Enqueue + +```bash +/opt/localradio/venv/bin/python /opt/localradio/scripts/enqueue_episode.py /path/to/episode.mp3 "Episode Title" +``` + +This creates a symlink in the queue directory. Liquidsoap picks it up and plays it with priority. diff --git a/config/feeds.yaml b/config/feeds.yaml new file mode 100644 index 0000000..0dd5c8e --- /dev/null +++ b/config/feeds.yaml @@ -0,0 +1,11 @@ +feeds: +- name: Example Podcast + url: https://feeds.example.com/podcast.xml + enabled: false + priority: 10 + max_episodes: 20 +- name: c + url: https://rss.libsyn.com/shows/467049/destinations/3949218.xml + enabled: true + priority: 10 + max_episodes: 20 diff --git a/config/icecast.xml b/config/icecast.xml new file mode 100644 index 0000000..aad3da0 --- /dev/null +++ b/config/icecast.xml @@ -0,0 +1,58 @@ + + Local + admin@localhost + + + 5 + 2 + 524288 + 30 + 15 + 10 + 1 + 65535 + + + + localradio_source + localradio_relay + admin + localradio_admin + + + localhost + + + 8000 + 127.0.0.1 + + + + /stream + /fallback + 1 + 0 + 0 + + + 1 + + + /usr/share/icecast2 + /opt/localradio/logs + /usr/share/icecast2/web + /usr/share/icecast2/admin + + + + + icecast_access.log + icecast_error.log + 3 + 10000 + + + + 0 + + diff --git a/config/localradio.env b/config/localradio.env new file mode 100644 index 0000000..0169974 --- /dev/null +++ b/config/localradio.env @@ -0,0 +1,10 @@ +# Local Radio Environment Configuration +# Override defaults here. Loaded by systemd services. + +LOCALRADIO_BASE=/opt/localradio + +# RSS poll interval in seconds (default: 300 = 5 minutes) +POLL_INTERVAL=300 + +# Download timeout in seconds (default: 600) +DOWNLOAD_TIMEOUT=600 diff --git a/config/station.liq b/config/station.liq new file mode 100644 index 0000000..4356805 --- /dev/null +++ b/config/station.liq @@ -0,0 +1,156 @@ +#!/usr/bin/liquidsoap + +# ============================================================================= +# Local Radio Station — Liquidsoap Configuration +# ============================================================================= +# Source priority (highest to lowest): +# 1. Podcast queue (from queue/ directory) +# 2. Scheduled music (time/day-based playlists) +# 3. Emergency fallback (static safe playlist) +# ============================================================================= + +settings.log.file.path := "/opt/localradio/logs/liquidsoap.log" +settings.log.level := 3 + +# --- Paths ------------------------------------------------------------------- + +music_base = "/opt/localradio/media/music" +queue_dir = "/opt/localradio/state/queue" +fallback_dir = "/opt/localradio/media/fallback" + +# --- Icecast output settings ------------------------------------------------- + +icecast_host = "localhost" +icecast_port = 8000 +icecast_password = "localradio_source" +icecast_mount = "/stream" + +# --- Emergency Fallback ------------------------------------------------------ +# A safe playlist that always has content. Put at least one audio file in +# /opt/localradio/media/fallback/ to guarantee the stream never dies. + +fallback_source = playlist( + mode = "randomize", + reload_mode = "watch", + fallback_dir +) + +# --- Scheduled Music Sources -------------------------------------------------- +# Each playlist watches its directory for changes. If files are added/removed, +# the playlist updates automatically. + +morning_music = playlist( + mode = "randomize", + reload_mode = "watch", + "#{music_base}/morning" +) + +day_music = playlist( + mode = "randomize", + reload_mode = "watch", + "#{music_base}/day" +) + +night_music = playlist( + mode = "randomize", + reload_mode = "watch", + "#{music_base}/night" +) + +weekend_music = playlist( + mode = "randomize", + reload_mode = "watch", + "#{music_base}/weekend" +) + +# --- Time-based music schedule ------------------------------------------------ +# switch() picks the first matching source based on time predicates. +# Predicates use: {