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>
275 lines
8.9 KiB
Markdown
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.
|