# 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.