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

  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

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.

Description
Self-hosted personal radio station with Flask + React web UI
Readme 102 KiB
Languages
Python 45.2%
JavaScript 44.1%
Shell 7.7%
CSS 1.9%
Dockerfile 0.7%
Other 0.4%