A self-hosted internet radio station. A small Rust scheduler decides what plays and when, prepares the audio ahead of time, and feeds it to Liquidsoap, which streams a continuous MP3 to Icecast.
scheduler (Rust) ──push file path──> liquidsoap ──stream──> icecast ──> listeners
│ reads config.yaml │
│ downloads / picks clips └─ mixes a music bed under the programming
└─ pushes over telnet (port 1234)
The scheduler ticks every few seconds. For each schedule it:
- Fires when the slot is near (
leadseconds before the slot time). - Resolves the action into a local audio file (downloading if needed).
- Pushes the file to a Liquidsoap lane over telnet.
Between programmed clips, Liquidsoap plays a shuffled music bed from the
music/ playlist, so the stream is never silent.
Everything lives in config/config.yaml.
tick_seconds: 10 # scheduler poll interval (optional)
liquidsoap_addr: "127.0.0.1:1234"
station_name: "Radio Saeed" # radio's display name (web ui)
stream_url: "http://host:8003/radio" # where the web player connects
schedules:
- name: history_of_philosophy
title: "History of Philosophy" # human-facing label (web ui + on-air metadata)
lane: next # next | duck | takeover
lead: 300 # prepare 300s before the slot
every: "8h" # OR time: "10:00" (pick one)
select: sequential # random | shuffle | sequential
action:
type: youtube
url: "https://www.youtube.com/playlist?list=..."
cache: content/media/cachetitle is optional and falls back to name. It's what listeners see in the web
UI and what Icecast broadcasts as the track title.
Each schedule uses exactly one:
time: "10:00"— daily at that clock time.every: "8h"— repeating, aligned to midnight (8h→ 00:00, 08:00, 16:00). Units:h,m,s.
| Lane | Behaviour |
|---|---|
next |
Queued; plays after the current track. Normal programming. |
duck |
Overlaid on top; the bed/programming ducks to 25%. |
takeover |
Preempts everything and plays immediately. |
random— independent random pick each time.shuffle— random without repeats until the pool is exhausted.sequential— plays in order, resuming across restarts.
Selector positions are persisted to RADIOPAL_STATE_FILE so sequential/shuffle
survive restarts.
youtube — lists a channel/playlist with yt-dlp, picks one via the selector,
and downloads audio as MP3 into cache.
action:
type: youtube
url: "https://www.youtube.com/@channel"
cache: content/media/cachestatic — plays local audio files from a directory (searched recursively).
action:
type: static
dir: content/radio/short_storiesganjoor — fetches a random Persian poem and its recitation from
ganjoor.net and plays the audio. If the poem has no
recitation, the slot simply plays nothing.
action:
type: ganjoor
poet_id: 2 # 0 = random poet (e.g. حافظ 2, سعدی 7, خیام 3)
cache: content/media/cacheaudiobook — plays one chapter of a single audiobook file per slot. Reads
the file's embedded chapter markers with ffprobe, extracts the chosen chapter
with ffmpeg, and caches it. The on-air title is the chapter's own title.
action:
type: audiobook
file: content/books/a_tale_of_two_cities.m4b
cache: content/media/cache
chunk_minutes: 15 # only used if the file has NO chapter markersWorks with any .m4b/.m4a/.mp4 that carries chapters (check with
ffprobe -show_chapters yourfile). Use sequential to read the book start→
finish across slots — it resumes across restarts and wraps to the start when
finished. Files with no chapter markers fall back to fixed-length
chunk_minutes slices.
podcast — reads an RSS feed and downloads an episode's enclosure MP3.
action:
type: podcast
url: "https://feeds.example.com/show.xml"
cache: content/media/cache
limit: 50 # optional: keep only the newest N episodesPodcast feeds are a rolling window — most hosts expose only the recent
episodes, not the full back-catalog. So the action plays from whatever the feed
currently lists. Episodes are keyed by a stable id (pubDate + guid), so:
sequentialplays oldest→newest and resumes across restarts,shuffle/randomdedupe correctly even as the window shifts,- downloads dedupe by episode (enclosure tracking-redirects are followed).
The scheduler serves a minimal web page (web/index.html) with a player, the
station name, the currently-playing program, and a schedule table with live
countdowns to each program's next run (the soonest is flagged "up next"). It
listens on 0.0.0.0:8080 by default.
GET /— the UI.GET /api/state— JSON:{ station, stream_url, now, schedules }, where each schedule includes anext_run(unix seconds) for the countdown.
The in-page player points at stream_url (your Icecast stream); set it and
station_name in config.yaml.
To fully theme the UI (RTL layout, Persian fonts, your own HTML) without forking
or rebuilding, drop an index.html at config/index.html. If that file exists
it's served at /; otherwise the built-in UI is used. The file is read on each
request, so edits show up on refresh — no restart needed.
Your custom page just consumes the same GET /api/state JSON, so start from the
built-in web/index.html as a template. The path is configurable:
web_index: "config/index.html" # or set RADIOPAL_WEB_INDEXFonts must be self-contained if your deployment blocks outbound requests —
bundle the font or embed it as a data: URI in your index.html.
"Now playing" is read live from Liquidsoap (request.on_air), so it reflects the
actual on-air audio — music bed or program. If Liquidsoap can't be reached, it
falls back to the last clip the scheduler pushed.
With Docker Compose (scheduler + Liquidsoap + Icecast):
cp .env.example .env # set host paths / image
docker compose up -d --buildThe stream is served by Icecast (default mount /radio).
| Variable | Default | Purpose |
|---|---|---|
RADIOPAL_CONFIG |
config/config.yaml |
Path to the config file |
RADIOPAL_LIQUIDSOAP_ADDR |
127.0.0.1:1234 |
Liquidsoap telnet address |
RADIOPAL_STATE_FILE |
selector_state.json |
Where selector state is persisted |
RADIOPAL_WEB_ADDR |
0.0.0.0:8080 |
Web UI bind address |
RADIOPAL_WEB_INDEX |
config/index.html |
Custom UI file (falls back to built-in) |
cargo build
cargo test
cargo run # loads config/config.yaml, needs Liquidsoap on :1234Requires yt-dlp (with deno), ffmpeg, and curl on the host for the
youtube and ganjoor actions — all bundled in the Docker image.