radio/README.md
profit 3d635b742c 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>
2026-03-13 19:01:33 -07:00

275 lines
8.9 KiB
Markdown

# 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 <this-repo> /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 `<guid>`, 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/<feed-name>/`
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 <url>`
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.