Appliance Recipe: Internet Radio Station (Icecast/Liquidsoap)
A kldload appliance that runs multiple internet radio stations from a single server — Icecast for streaming, Liquidsoap for playlist automation and mixing, ZFS for media storage with compression and snapshots. Run 30 stations from one box. What used to take FreeBSD jails now runs natively with per-station ZFS datasets.
What this replaces: Commercial radio automation software (SAM Broadcaster, RadioBOSS, PlayoutONE), multiple VPS instances, FreeBSD jail farms. One server, one OS, unlimited stations.
Companion recipes: Plex on ZFS covers media serving for video. This recipe focuses on audio streaming, playlist automation, crossfade mixing, and multi-station management.
Architecture
kldload Internet Radio Server
MEDIA STORAGE AUTOMATION DELIVERY
───────────── ────────────── ────────
┌─ liquidsoap@station1 ──┐
/srv/music ────────────┤ (playlist, crossfade, │
(ZFS, zstd) │ scheduled shows, ├──► Icecast2 ──► Listeners
│ fallback/silence det) │ :8000 (web/apps)
/srv/stations/station1 ─┤ │
(per-station media) ├─ liquidsoap@station2 ──┤ /station1
│ (indie rock rotation) ├──► /station2
/srv/stations/station2 ─┤ │ /station3
(per-station media) ├─ liquidsoap@station3 ──┤ ...
│ (jazz overnight) │ /station30
/srv/playlists ─────────┤ │
(auto-generated M3U) ├─ ... │
│ │
/srv/archive ───────────┤─ liquidsoap@station30 ─┘
(recorded shows) │ (talk radio archive)
│
└─ playlist-generator.py ──► cron (hourly)
(scan library,
group by genre,
shuffle + weight)
MONITORING: Icecast admin (:8000/admin) + liquidsoap telnet (:1234-1263)
Hardware
| Component | Examples | Cost |
|---|---|---|
| Server (10 stations) | Any x86_64, 2+ cores, 4 GB RAM | $150-400 |
| Server (30 stations) | Any x86_64, 4+ cores, 8 GB RAM | $300-800 |
| Storage (music library) | 1-4 TB SSD or HDD — depends on library size | $50-200 |
| Network | 1 Gbps for ~500 concurrent 128kbps listeners | included |
| USB stick | kldload installer | $5 |
No GPU required. Liquidsoap and Icecast are CPU-light — encoding 30 simultaneous 128kbps OGG/MP3 streams uses roughly one modern core. The bottleneck is network bandwidth for listeners, not processing power. A single gigabit link supports approximately 500 concurrent listeners at 128kbps, or 250 at 256kbps.
Step 1: Install kldload
cat > /tmp/answers.env << 'EOF'
KLDLOAD_DISTRO=debian
KLDLOAD_DISK=/dev/sda
KLDLOAD_HOSTNAME=radio-server
KLDLOAD_USERNAME=admin
KLDLOAD_PASSWORD=changeme
KLDLOAD_PROFILE=server
KLDLOAD_NET_METHOD=dhcp
EOF
kldload-install-target --config /tmp/answers.env
Step 2: Install the radio stack
# Icecast streaming server
apt install -y icecast2
# Liquidsoap — playlist automation, mixing, encoding
# The Debian repo version works fine for most setups
apt install -y liquidsoap
# Audio codecs and tools
apt install -y ffmpeg vorbis-tools lame flac
# Metadata and tagging
apt install -y id3v2 python3-mutagen
# Useful extras
apt install -y python3 python3-pip sox libsox-fmt-all
Step 3: ZFS datasets for media
# Main music library — shared across all stations
# zstd compression works well on FLAC/WAV (40-60% savings)
# and still saves 5-10% on MP3/OGG metadata overhead
zfs create -o mountpoint=/srv/music \
-o compression=zstd \
-o recordsize=128K \
-o atime=off \
rpool/srv/music
# Auto-generated playlists (small text files, compress heavily)
zfs create -o mountpoint=/srv/playlists \
-o compression=zstd \
-o atime=off \
rpool/srv/playlists
# Recorded shows archive
zfs create -o mountpoint=/srv/archive \
-o compression=zstd-3 \
-o recordsize=128K \
-o atime=off \
rpool/srv/archive
# Per-station datasets — each station gets its own dataset
# with independent quotas, compression stats, and snapshots
for i in $(seq 1 30); do
zfs create -o mountpoint=/srv/stations/station${i} \
-o compression=zstd \
-o recordsize=128K \
-o atime=off \
-o quota=50G \
rpool/srv/stations/station${i}
done
# Snapshot the library daily — accidental deletes, bad taggers, roll back
cat > /etc/cron.d/radio-snapshots << 'EOF'
0 4 * * * root for ds in music playlists archive; do zfs snapshot rpool/srv/${ds}@auto-$(date +\%Y\%m\%d); done
0 4 * * * root for i in $(seq 1 30); do zfs snapshot rpool/srv/stations/station${i}@auto-$(date +\%Y\%m\%d); done
15 4 * * * root for ds in music playlists archive; do zfs list -t snapshot -o name -H rpool/srv/${ds} | grep @auto- | head -n -30 | xargs -r -n1 zfs destroy; done
15 4 * * * root for i in $(seq 1 30); do zfs list -t snapshot -o name -H rpool/srv/stations/station${i} | grep @auto- | head -n -30 | xargs -r -n1 zfs destroy; done
EOF
zfs send one station's entire library to another server for a mirror. You can clone a station dataset to test a new playlist structure without risking the live station. And the shared music library (/srv/music) is a separate dataset that all stations read from — one copy of every song, deduplicated by the filesystem, with its own snapshot policy. This is the radio equivalent of the per-movie dataset pattern from the Plex recipe.Why ZFS is perfect here: Audio files sit on disk and get read sequentially — ZFS's prefetch and ARC cache keep hot playlists in memory. Per-station datasets mean one station's runaway recording can't fill the disk for everyone else (quotas). Snapshots let you roll back the entire library if a bulk re-tagger corrupts metadata. And zstd compression on FLAC/WAV files saves 40-60% of disk space with zero CPU overhead at read time.
Step 4: Configure Icecast
cat > /etc/icecast2/icecast.xml << 'ICECAST'
<icecast>
<location>Radio Server</location>
<admin>admin@radio-server.local</admin>
<limits>
<clients>1000</clients>
<sources>60</sources>
<queue-size>524288</queue-size>
<client-timeout>30</client-timeout>
<header-timeout>15</header-timeout>
<source-timeout>10</source-timeout>
<burst-on-connect>1</burst-on-connect>
<burst-size>65535</burst-size>
</limits>
<authentication>
<!-- Change these passwords before going live -->
<source-password>changeme-source</source-password>
<relay-password>changeme-relay</relay-password>
<admin-user>admin</admin-user>
<admin-password>changeme-admin</admin-password>
</authentication>
<hostname>radio-server.local</hostname>
<listen-socket>
<port>8000</port>
</listen-socket>
<!-- Mount points — one per station -->
<mount>
<mount-name>/station1</mount-name>
<fallback-mount>/silence</fallback-mount>
<fallback-override>1</fallback-override>
<max-listeners>200</max-listeners>
</mount>
<mount>
<mount-name>/station2</mount-name>
<fallback-mount>/silence</fallback-mount>
<fallback-override>1</fallback-override>
<max-listeners>200</max-listeners>
</mount>
<!-- Repeat for each station... or generate programmatically: -->
<!-- for i in $(seq 3 30); do
echo " <mount>"
echo " <mount-name>/station${i}</mount-name>"
echo " <fallback-mount>/silence</fallback-mount>"
echo " <fallback-override>1</fallback-override>"
echo " <max-listeners>200</max-listeners>"
echo " </mount>"
done -->
<mount>
<mount-name>/silence</mount-name>
<max-listeners>10</max-listeners>
</mount>
<fileserve>1</fileserve>
<paths>
<logdir>/var/log/icecast2</logdir>
<webroot>/usr/share/icecast2/web</webroot>
<adminroot>/usr/share/icecast2/admin</adminroot>
<alias source="/" destination="/status.xsl"/>
</paths>
<logging>
<accesslog>access.log</accesslog>
<errorlog>error.log</errorlog>
<loglevel>3</loglevel>
</logging>
</icecast>
ICECAST
# Enable and start Icecast
systemctl enable --now icecast2
# Verify: http://radio-server:8000 shows the Icecast status page
# Admin: http://radio-server:8000/admin (admin / changeme-admin)
Step 5: Liquidsoap station configs
# Create config directory
mkdir -p /etc/liquidsoap
# Station 1 — full-featured example with all the bells and whistles
cat > /etc/liquidsoap/station1.liq << 'STATION'
#!/usr/bin/liquidsoap
# Station 1 — Classic Rock
# Reads from playlist, crossfades, handles silence, scheduled shows
set("log.file.path", "/var/log/liquidsoap/station1.log")
set("server.telnet", true)
set("server.telnet.port", 1234)
# Main playlist — random rotation from auto-generated M3U
main_playlist = playlist(
mode="randomize",
reload=3600, # Re-read playlist every hour
reload_mode="rounds", # Finish current round before reloading
"/srv/playlists/station1.m3u"
)
# Jingles — station ID, bumpers, promos
jingles = playlist(
mode="randomize",
"/srv/stations/station1/jingles"
)
# Insert a jingle every 4 tracks
radio = rotate(
weights=[4, 1],
[main_playlist, jingles]
)
# Scheduled shows — override the main rotation at specific times
# Saturday night special: 8pm-midnight
saturday_night = playlist(
mode="randomize",
"/srv/playlists/station1-saturday.m3u"
)
radio = switch([
({ 6w and 20h-23h59m59s }, saturday_night),
({ true }, radio)
])
# Crossfade between tracks (5 second overlap)
radio = crossfade(
duration=5.0,
fade_in=3.0,
fade_out=3.0,
radio
)
# Silence detection — if 5 seconds of silence, skip to next track
radio = skip_blank(
max_blank=5.0,
threshold=-40.0,
radio
)
# Fallback chain: main radio -> emergency jingle loop -> sine wave
emergency = single("/srv/stations/station1/emergency.mp3")
safety = sine(440.0)
radio = fallback(
track_sensitive=false,
[radio, emergency, safety]
)
# Normalize audio levels
radio = normalize(radio)
# Output to Icecast — OGG Vorbis at 128kbps
output.icecast(
%vorbis(quality=0.4),
host="localhost",
port=8000,
password="changeme-source",
mount="/station1",
name="Station 1 - Classic Rock",
genre="Rock",
description="Classic rock, all day, all night",
radio
)
# Also output MP3 for compatibility with older players
output.icecast(
%mp3(bitrate=128),
host="localhost",
port=8000,
password="changeme-source",
mount="/station1.mp3",
name="Station 1 - Classic Rock (MP3)",
genre="Rock",
description="Classic rock, all day, all night",
radio
)
STATION
# Make it executable
chmod +x /etc/liquidsoap/station1.liq
Step 6: Auto-generated playlists
cat > /usr/local/bin/playlist-generator.py << 'PYEOF'
#!/usr/bin/env python3
"""
Scan the music library, group by genre/directory, generate M3U playlists
with weighted random shuffle. Avoids repeating tracks within a configurable
window. Runs on cron — regenerates playlists hourly.
"""
import os
import random
import hashlib
from pathlib import Path
from datetime import datetime
MUSIC_ROOT = "/srv/music"
PLAYLIST_DIR = "/srv/playlists"
STATION_DIR = "/srv/stations"
AUDIO_EXTENSIONS = {".mp3", ".ogg", ".flac", ".wav", ".m4a", ".opus"}
# How many tracks before a repeat is allowed
NO_REPEAT_WINDOW = 50
def scan_directory(path):
"""Recursively find all audio files in a directory."""
tracks = []
for root, dirs, files in os.walk(path):
for f in files:
if Path(f).suffix.lower() in AUDIO_EXTENSIONS:
tracks.append(os.path.join(root, f))
return sorted(tracks)
def shuffle_no_repeat(tracks, window=NO_REPEAT_WINDOW):
"""
Shuffle tracks ensuring no track appears within 'window' positions
of its previous occurrence. Uses a reservoir approach.
"""
if len(tracks) <= window:
random.shuffle(tracks)
return tracks
# Seed with current hour so playlists change every regen
seed = int(datetime.now().strftime("%Y%m%d%H"))
rng = random.Random(seed)
result = []
pool = list(tracks)
rng.shuffle(pool)
recent = set()
while pool:
# Find a track not in the recent window
found = False
for i, track in enumerate(pool):
track_id = hashlib.md5(track.encode()).hexdigest()[:8]
if track_id not in recent:
result.append(track)
recent.add(track_id)
if len(recent) > window:
# Remove oldest from recent set by rebuilding
# (approximation — works well enough for radio)
oldest_idx = len(result) - window - 1
if oldest_idx >= 0:
old_id = hashlib.md5(
result[oldest_idx].encode()
).hexdigest()[:8]
recent.discard(old_id)
pool.pop(i)
found = True
break
if not found:
# All remaining tracks are in the recent window — just append
result.extend(pool)
break
return result
def write_m3u(tracks, output_path):
"""Write an M3U playlist file."""
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w") as f:
f.write("#EXTM3U\n")
for track in tracks:
f.write(f"{track}\n")
def generate_genre_playlists():
"""
Each subdirectory of MUSIC_ROOT is treated as a genre.
Generate one playlist per genre.
"""
if not os.path.exists(MUSIC_ROOT):
return
for genre_dir in sorted(os.listdir(MUSIC_ROOT)):
genre_path = os.path.join(MUSIC_ROOT, genre_dir)
if not os.path.isdir(genre_path):
continue
tracks = scan_directory(genre_path)
if not tracks:
continue
shuffled = shuffle_no_repeat(tracks)
playlist_name = genre_dir.lower().replace(" ", "-")
write_m3u(shuffled, f"{PLAYLIST_DIR}/genre-{playlist_name}.m3u")
print(f" {genre_dir}: {len(tracks)} tracks")
def generate_station_playlists():
"""
Each station directory can have its own music.
Generate a combined playlist from station-specific + shared library tracks.
"""
if not os.path.exists(STATION_DIR):
return
for station_dir in sorted(os.listdir(STATION_DIR)):
station_path = os.path.join(STATION_DIR, station_dir)
if not os.path.isdir(station_path):
continue
tracks = scan_directory(station_path)
if not tracks:
continue
shuffled = shuffle_no_repeat(tracks)
write_m3u(shuffled, f"{PLAYLIST_DIR}/{station_dir}.m3u")
print(f" {station_dir}: {len(tracks)} tracks")
def generate_master_playlist():
"""Generate one big playlist from the entire library."""
tracks = scan_directory(MUSIC_ROOT)
if tracks:
shuffled = shuffle_no_repeat(tracks)
write_m3u(shuffled, f"{PLAYLIST_DIR}/all-music.m3u")
print(f" master: {len(tracks)} tracks")
if __name__ == "__main__":
print(f"[{datetime.now()}] Generating playlists...")
generate_genre_playlists()
generate_station_playlists()
generate_master_playlist()
print("Done.")
PYEOF
chmod +x /usr/local/bin/playlist-generator.py
# Run on first setup
/usr/local/bin/playlist-generator.py
# Regenerate playlists every hour via cron
cat > /etc/cron.d/playlist-generator << 'EOF'
0 * * * * root /usr/local/bin/playlist-generator.py >> /var/log/playlist-generator.log 2>&1
EOF
Step 7: Multi-station setup
# Systemd template unit — one instance per station
cat > /etc/systemd/system/liquidsoap@.service << 'UNIT'
[Unit]
Description=Liquidsoap radio station %i
After=network.target icecast2.service
Requires=icecast2.service
[Service]
Type=simple
ExecStart=/usr/bin/liquidsoap /etc/liquidsoap/%i.liq
Restart=always
RestartSec=5
User=liquidsoap
Group=liquidsoap
# Each station gets its own telnet port (1234 + station number)
Environment=LIQUIDSOAP_TELNET_PORT=0
[Install]
WantedBy=multi-user.target
UNIT
# Create a minimal config for each station
# (copy and customize from the station1 template above)
for i in $(seq 2 30); do
cat > /etc/liquidsoap/station${i}.liq << STNCFG
#!/usr/bin/liquidsoap
set("log.file.path", "/var/log/liquidsoap/station${i}.log")
set("server.telnet", true)
set("server.telnet.port", $((1233 + i)))
main = playlist(mode="randomize", reload=3600,
"/srv/playlists/station${i}.m3u")
jingles = playlist(mode="randomize",
"/srv/stations/station${i}/jingles")
radio = rotate(weights=[4, 1], [main, jingles])
radio = crossfade(duration=5.0, fade_in=3.0, fade_out=3.0, radio)
radio = skip_blank(max_blank=5.0, threshold=-40.0, radio)
emergency = sine(440.0)
radio = fallback(track_sensitive=false, [radio, emergency])
radio = normalize(radio)
output.icecast(%vorbis(quality=0.4),
host="localhost", port=8000,
password="changeme-source",
mount="/station${i}",
name="Station ${i}",
radio)
STNCFG
chmod +x /etc/liquidsoap/station${i}.liq
done
# Create log and jingle directories
mkdir -p /var/log/liquidsoap
for i in $(seq 1 30); do
mkdir -p /srv/stations/station${i}/jingles
done
chown -R liquidsoap:liquidsoap /var/log/liquidsoap
# Enable and start all 30 stations
systemctl daemon-reload
for i in $(seq 1 30); do
systemctl enable --now liquidsoap@station${i}
done
# Check status
systemctl list-units 'liquidsoap@*' --no-pager
Step 8: Recording and archiving
# Add recording output to any station's liquidsoap config.
# This records the live stream to the ZFS archive dataset.
# Add these lines to a station's .liq file (e.g., station1.liq):
# Record every show to a date-stamped file
# Files rotate every hour (3600 seconds)
output.file(
%vorbis(quality=0.6),
"/srv/archive/station1/%Y-%m-%d/%H-%M.ogg",
reopen_on_metadata=false,
reopen_delay=3600.0,
radio
)
# ZFS snapshots handle the rest — every recording is protected.
# To find a specific show from last Tuesday:
# ls /srv/archive/station1/2026-03-24/
#
# To recover a deleted recording:
# ls /srv/archive/.zfs/snapshot/auto-20260324/station1/
#
# To replicate archives to a backup server:
# zfs send rpool/srv/archive@auto-20260327 | \
# ssh backup-server zfs recv tank/radio-archive
Step 9: Monitoring
# Icecast admin page — listener counts, mount point status
# http://radio-server:8000/admin
# Shows: active mount points, connected listeners per station,
# source uptime, bytes served, peak listeners
# Quick listener count per station (from the command line)
curl -s -u admin:changeme-admin \
http://localhost:8000/admin/stats | \
grep -oP '<source mount="[^"]*">.*?<listeners>\d+' | \
sed 's/<source mount="//;s/">.*<listeners>/ listeners: /'
# Liquidsoap telnet interface — live control
# Each station has its own telnet port (1234 for station1, 1235 for station2, etc.)
# Connect:
telnet localhost 1234
# Useful telnet commands:
# help — list all commands
# request.queue — show upcoming tracks
# request.push /path.mp3 — queue a specific track next
# skip — skip current track
# metadata — show current track metadata
# uptime — how long this station has been running
# version — liquidsoap version
# ZFS storage stats per station
zfs list -o name,used,quota,compressratio -r rpool/srv/stations
# Example output:
# NAME USED QUOTA RATIO
# rpool/srv/stations/station1 12.4G 50G 1.45x
# rpool/srv/stations/station2 8.7G 50G 1.52x
# rpool/srv/stations/station3 23.1G 50G 1.38x
# Overall storage
zfs list -o name,used,compressratio,available rpool/srv
Step 10: Systemd services
# Summary of all services
# Icecast — the streaming server (one instance, all mount points)
systemctl status icecast2
# Liquidsoap — one instance per station (template unit)
systemctl status liquidsoap@station1
systemctl status liquidsoap@station2
# ... through station30
# Start/stop individual stations without affecting others
systemctl stop liquidsoap@station15
systemctl start liquidsoap@station15
# Restart all stations (e.g., after config change)
systemctl restart 'liquidsoap@*'
# View logs
journalctl -u icecast2 -f
journalctl -u liquidsoap@station1 -f
# Boot order: icecast2 starts first, then liquidsoap instances connect to it
# If Icecast restarts, liquidsoap instances auto-reconnect (Restart=always)
Why ZFS matters here
Compression for audio
FLAC and WAV files compress 40-60% with zstd — a 1 TB music library stored in 500 GB of disk space. Even pre-compressed formats like MP3 and OGG see 5-10% savings because zstd compresses the ID3 tags, headers, and metadata padding that every audio file carries. ZFS applies compression transparently — Liquidsoap reads files at full speed from the ARC cache.
Per-station datasets
Each station gets its own ZFS dataset with independent quotas, compression ratios, and snapshot schedules. Station 12 filling up its 50 GB quota does not affect Station 1. You can check per-station disk usage with zfs list, set different compression policies (lossless archives get zstd-9, working directories get zstd), and snapshot each station independently.
Snapshots protect the library
Accidentally delete half the classic rock collection? zfs rollback rpool/srv/music@auto-20260326 — the entire library is back in seconds. A bad metadata tagger corrupts ID3 tags across 10,000 files? Snapshot recovery. A new intern drags the wrong folder to the trash? Snapshots. Daily snapshots cost almost nothing (they only store changed blocks), and they have saved more music libraries than any backup tool.
Dedup for syndicated content
When 30 stations share the same jingles, station IDs, ads, and bumper music, ZFS deduplication stores each unique file block exactly once. Ten stations using the same 30-second sponsor spot? Stored once. This matters most for syndicated content, shared sound effects libraries, and common intro/outro music. Enable with zfs set dedup=on rpool/srv/stations (requires sufficient RAM for the dedup table — roughly 5 GB per TB of data).
Licensing note
Internet radio has specific music licensing requirements that differ from traditional broadcast radio. In the United States:
- SoundExchange — collects royalties for the digital performance of sound recordings. Required for all internet radio stations playing copyrighted music. Rates are per-listener-per-song.
- ASCAP, BMI, SESAC — performance rights organizations that license the underlying musical compositions (not the recordings). You typically need licenses from all three.
- DMCA compliance — internet radio must follow specific rules: no pre-announced playlists, no more than 3 songs from the same album in a row, no more than 4 songs from the same artist in a 3-hour window. Liquidsoap's random rotation mode helps with compliance.
- Outside the US — licensing varies by country. Many countries have a single collection society (PRS in the UK, GEMA in Germany, SOCAN in Canada). Check your local requirements.
- Royalty-free and Creative Commons — stations playing only royalty-free, CC-licensed, or original music do not need these licenses. The Free Music Archive and Jamendo are good sources.