Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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)
git clone <this-repo> /tmp/localradio-src
cd /tmp/localradio-src
bash scripts/setup.sh
2. Add Content
# 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:
feeds:
- name: "My Podcast"
url: "https://feeds.example.com/mypodcast.xml"
enabled: true
priority: 10
max_episodes: 30
4. Start Services
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:
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:
- 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:
/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):
output.icecast(
%mp3(bitrate=320, samplerate=44100, stereo=true),
...
)
Opus (high quality):
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:
POLL_INTERVAL=300 # seconds (default: 5 minutes)
Restart the poller: systemctl restart localradio-poller
How the Queue and Dedup Logic Works
Episode Discovery
- The poller reads
feeds.yamlfor enabled feeds - Each feed is parsed via
feedparser - For each entry, a GUID is extracted (from RSS
<guid>, or hashed from the audio URL) - The GUID + feed name are checked against SQLite — if the pair exists, it's skipped
Download and Enqueue
- New episodes are downloaded to
media/podcasts/<feed-name>/ - Partial downloads use a
.partsuffix and are renamed on completion - A symlink is created in
state/queue/with a timestamp prefix for FIFO ordering - The episode is recorded in both the
episodesandqueuetables
Playback
- Liquidsoap watches
state/queue/as a playlist innormal(sequential) mode - When files appear, Liquidsoap plays them with priority over music
track_sensitive=trueensures music finishes its current track before podcast takeover- 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
- Check fallback:
ls /opt/localradio/media/fallback/— must have at least one audio file - Check music dirs:
ls /opt/localradio/media/music/morning/(etc.) - Check Liquidsoap logs:
journalctl -u localradio-stream -f - Run healthcheck:
bash /opt/localradio/scripts/healthcheck.sh
Podcasts not downloading
- Check poller logs:
journalctl -u localradio-poller -forcat /opt/localradio/logs/poller.log - Test the feed:
/opt/localradio/venv/bin/python /opt/localradio/scripts/test_feed.py <url> - Check the database:
/opt/localradio/venv/bin/python /opt/localradio/scripts/queue_status.py - Verify feeds are
enabled: trueinfeeds.yaml
Icecast won't start
- Check if port 8000 is in use:
ss -tlnp | grep 8000 - Check logs:
journalctl -u localradio-icecast -f - Validate XML:
xmllint --noout /opt/localradio/config/icecast.xml
Liquidsoap errors
- Test config syntax:
liquidsoap --check /opt/localradio/config/station.liq - Check logs:
cat /opt/localradio/logs/liquidsoap.log - Common issue: empty playlist directories — add files or ensure fallback has content
Reset everything
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
# 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
/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.