A minimal, single-file radio player powered by Liquidsoap and Icecast.
No CMS. No database admin. No app required. Just your music, a stream, and a simple web player.
This guide shows you how to build a self-hosted internet radio station that's lightweight, secure, and fully open source.
It uses three core components:
| Component | Role | Example Host |
|---|---|---|
| Transcoder (Liquidsoap Host) | Reads and plays your local music library, applies audio processing, and streams encoded MP3 audio to Icecast. | radio.example.com |
| Icecast Server | Receives the encoded audio stream and serves it to listeners, exposing now-playing metadata via JSON. | stream.example.com |
| Web Server | Hosts the single-file HTML player and optionally proxies the Icecast stream for HTTPS. | www.example.com |
Music Files → Liquidsoap (via ls_radio.py)
↓
Icecast Server
↓
nginx (proxy)
↓
Listeners
Metadata Flow:
Icecast status-json.xsl → index.html → artwork & now-playing info.
All three services can run on a single VPS or be separated for scalability.
sudo apt update
sudo apt install liquidsoap python3 ffmpeg sqlite3Note: ls_radio.py uses only Python stdlib - no pip packages required.
sudo apt install icecast2sudo apt install nginx
# Or use any static hosting: GitHub Pages, Netlify, Cloudflare Pages, etc.Edit /etc/icecast2/icecast.xml:
<location>Your Location</location>
<admin>your-email@example.com</admin>
<hostname>stream.example.com</hostname>
...
<authentication>
<source-password>CHANGEME-source</source-password>
<relay-password>CHANGEME-relay</relay-password>
<admin-user>admin</admin-user>
<admin-password>CHANGEME-admin</admin-password>
</authentication>
...IMPORTANT: Change all default passwords before exposing to the internet.
Then start Icecast:
sudo systemctl enable icecast2
sudo systemctl start icecast2Verify it's running:
sudo systemctl status icecast2
curl -I http://localhost:8000/Note: ls_radio.py uses os.fork() which is POSIX/Linux-only. It will not work on Windows.
Copy ls_radio.py to /usr/local/bin/ on your transcoder host:
sudo cp ls_radio.py /usr/local/bin/
sudo chmod +x /usr/local/bin/ls_radio.pyCreate a short file of silence (Liquidsoap uses this when no track is picked):
sudo mkdir -p /usr/share/liquidsoap
sudo ffmpeg -f lavfi -i anullsrc=r=44100:cl=stereo -t 1 -q:a 9 -acodec libmp3lame /usr/share/liquidsoap/_silence.mp3The picker outputs a file path to stdout. Liquidsoap reads that path to request the next song.
On first run with an empty cache:
pick-nextreturns a random track immediately (viaquick_random_dart())- A background process forks to build the full cache
- Subsequent picks use the cache for better separation logic
- Cache rebuilds automatically every 24 hours (configurable via
LS_RESCAN_SEC)
Create a user and home directory & song picker cache directory:
sudo useradd -m -d /home/liquidsoap -s /bin/bash liquidsoap
sudo mkdir -p /var/lib/liquidsoap
sudo chown liquidsoap:liquidsoap /var/lib/liquidsoap
sudo -u liquidsoap /usr/bin/python3 /usr/local/bin/ls_radio.py initPre-build the cache (this can take a few minutes if you have a big library):
sudo -u liquidsoap /usr/bin/python3 /usr/local/bin/ls_radio.py rebuild-cacheCreate /etc/liquidsoap/stream.liq with your stream information:
def q(s) = string.quote(s) end
def on_start_meta(m) =
artist = if m["artist"] != "" then m["artist"] else "" end
title = if m["title"] != "" then m["title"] else "" end
file = if m["filename"] != "" then m["filename"] else "" end
log("ON-AIR: #{artist} - #{title} (#{file})")
ignore(process.run(
"/usr/bin/python3 /usr/local/bin/ls_radio.py track-start "
^ "--artist " ^ q(artist) ^ " "
^ "--title " ^ q(title) ^ " "
^ "--path " ^ q(file)
))
m
end
def next_request() =
uri = string.trim(process.read("/usr/bin/python3 /usr/local/bin/ls_radio.py pick-next"))
if uri == "" then
request.create("/usr/share/liquidsoap/_silence.mp3")
else
request.create(uri)
end
end
radio = request.dynamic(next_request)
radio = map_metadata(on_start_meta, radio)
radio = mksafe(crossfade(radio))
radio = normalize(radio, target=-16.0, threshold=-22.0, window=0.5)
radio = compress(radio,
threshold=-18.0,
ratio=2.5,
attack=0.01,
release=0.3,
gain=3.0
)
radio = limit(radio, threshold=-0.5, attack=0.005, release=0.1)
output.icecast(
%mp3(bitrate=192),
host="stream.example.com", port=8000, password="YOUR-SOURCE-PASSWORD",
mount="/live", name="Your Radio Station",
url="https://radio.example.com", genre="Various", public=true,
radio
)
Lock down permissions so you're not exposing your Icecast source password:
sudo mkdir -p /etc/liquidsoap
sudo chown liquidsoap:liquidsoap /etc/liquidsoap
sudo chmod 0700 /etc/liquidsoap
sudo chmod 0600 /etc/liquidsoap/stream.liqCreate systemd service /etc/systemd/system/liquidsoap.service:
[Unit]
Description=Liquidsoap Stream
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=liquidsoap
Group=liquidsoap
WorkingDirectory=/home/liquidsoap
ExecStart=/usr/bin/liquidsoap /etc/liquidsoap/stream.liq
Restart=always
RestartSec=5
TimeoutStopSec=15
KillSignal=SIGINT
StandardOutput=journal
StandardError=journal
# ---- Tuning knobs (env) ----
Environment=LS_MUSIC_DIR=/srv/music
Environment=LS_DB=/var/lib/liquidsoap/liquidsoap.db
# Separation windows
Environment=LS_ARTIST_SEP_MIN=45
Environment=LS_TITLE_SEP_MIN=180
Environment=LS_TRACK_SEP_SEC=0
# Cache + lock behavior
Environment=LS_RESCAN_SEC=86400
Environment=LS_LOCK_STALE_SEC=3600
Environment=LS_TOP_N_DIRS=64
Environment=LS_FILES_PER_DIR_TRY=128
# Tags / scanning
Environment=LS_FFPROBE_TIMEOUT_S=0.8
Environment=LS_SCAN_EXTS=.mp3,.flac,.m4a,.ogg,.wav,.aac
Environment=LS_UNKNOWN_ARTIST_BUCKET=1
# History retention
Environment=LS_HISTORY_KEEP=10000
Environment=LS_HISTORY_KEEP_PATHS=20000
LimitNOFILE=131072
Nice=5
IOSchedulingClass=best-effort
IOSchedulingPriority=7
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectControlGroups=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectClock=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictNamespaces=true
RestrictRealtime=true
SystemCallArchitectures=native
ProtectSystem=strict
ReadWritePaths=/var/lib/liquidsoap
ReadOnlyPaths=/srv/music
ReadOnlyPaths=/home/liquidsoap
[Install]
WantedBy=multi-user.targetEnable and start:
sudo systemctl daemon-reload
sudo systemctl enable liquidsoap
sudo systemctl start liquidsoapOptional sanity check:
liquidsoap --check /etc/liquidsoap/stream.liqTwo versions available:
index.html- Requires nginx caching proxy (recommended for production)index.html.nocache- Direct iTunes API calls (simpler setup, may hit rate limits)
Add to your nginx http block (usually in /etc/nginx/nginx.conf):
# Enable album art caching
proxy_cache_path /var/cache/nginx/itunes keys_zone=itunes:10m inactive=14d max_size=2g;
resolver 1.1.1.1 1.0.0.1 valid=300s ipv6=off;
# Rudimentary scraper blocking
map $http_user_agent $block_scraper {
default 0;
~*(curl|wget|python|php|go-http-client|scrapy|httpclient) 1;
}
# Rate limiting
limit_conn_zone $binary_remote_addr zone=connperip:10m;
limit_req_zone $binary_remote_addr zone=reqperip:10m rate=30r/m;
# Icecast upstream
upstream icecast {
server 127.0.0.1:8000;
keepalive 32;
}Create /etc/nginx/sites-available/radio.example.com:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name radio.example.com;
ssl_certificate /etc/letsencrypt/live/radio.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/radio.example.com/privkey.pem;
# Block scrapers
if ($block_scraper) { return 403; }
# Force HTTPS
if ($scheme != "https") {
return 301 https://$host$request_uri;
}
client_max_body_size 10m;
sendfile on;
keepalive_timeout 15;
keepalive_requests 1000;
# Web player
location /radio {
alias /var/www/html/radio;
try_files $uri /index.html =404;
}
# Stream endpoint
location = /live {
default_type "";
if ($request_method = HEAD) {
add_header Accept-Ranges "bytes";
return 200;
}
proxy_pass http://icecast/live;
proxy_http_version 1.1;
proxy_set_header Connection "";
gzip off;
gunzip off;
proxy_set_header Accept-Encoding "";
proxy_buffering off;
proxy_request_buffering off;
proxy_set_header Icy-MetaData "0";
proxy_hide_header icy-metaint;
proxy_hide_header icy-name;
proxy_hide_header icy-url;
add_header Accept-Ranges "bytes" always;
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Expose-Headers "Content-Length,Content-Range,Accept-Ranges" always;
add_header Cache-Control "no-store" always;
proxy_read_timeout 12h;
send_timeout 12h;
proxy_redirect off;
limit_conn connperip 3;
}
# Status JSON
location = /status-json.xsl {
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_buffering off;
proxy_request_buffering off;
limit_req zone=reqperip burst=30 nodelay;
proxy_pass http://icecast;
proxy_redirect off;
}
# iTunes search proxy with caching
location /itunes/search {
proxy_pass https://itunes.apple.com/search$is_args$args;
proxy_cache itunes;
proxy_cache_valid 200 302 7d;
proxy_cache_valid 404 5m;
proxy_ignore_headers Set-Cookie Expires Cache-Control;
proxy_hide_header Content-Disposition;
add_header Content-Type "application/json; charset=utf-8";
add_header X-Cache-Status $upstream_cache_status always;
}
# iTunes artwork proxy with caching
location /itunes/art {
if ($arg_u = "") { return 400; }
proxy_pass $arg_u;
proxy_ssl_server_name on;
proxy_set_header Host $proxy_host;
proxy_cache itunes;
proxy_cache_key $arg_u;
proxy_cache_valid 200 30d;
proxy_cache_lock on;
proxy_ignore_headers Set-Cookie Expires Cache-Control;
proxy_hide_header Content-Disposition;
add_header Content-Type "image/jpeg";
add_header X-Cache-Status $upstream_cache_status always;
}
}Enable site and ensure cache directory:
sudo mkdir -p /var/cache/nginx
sudo chown www-data:www-data /var/cache/nginx
sudo ln -s /etc/nginx/sites-available/radio.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginxUse index.html.nocache instead of index.html. You still need nginx to proxy the stream but can skip the iTunes caching locations.
Warning: Without caching, you may hit iTunes API rate limits if you have many concurrent listeners.
The player needs fallback images when album art isn't found. Create three sizes:
# Option 1: From an existing logo/image
sudo apt install imagemagick
convert your-logo.png -resize 512x512 artwork-512.png
convert your-logo.png -resize 192x192 artwork-192.png
convert your-logo.png -resize 96x96 artwork-096.png
# Option 2: Simple colored placeholder
convert -size 512x512 xc:#2563eb -pointsize 72 -fill white -gravity center \
-annotate +0+0 "RADIO\nPLAYER" artwork-512.png
convert -size 192x192 xc:#2563eb -pointsize 32 -fill white -gravity center \
-annotate +0+0 "RADIO\nPLAYER" artwork-192.png
convert -size 96x96 xc:#2563eb -pointsize 16 -fill white -gravity center \
-annotate +0+0 "RADIO\nPLAYER" artwork-096.pngEdit index.html:
const STREAM_URL = 'https://radio.example.com/live';
const STATUS_URL = 'https://radio.example.com/status-json.xsl';
const TARGET_MOUNT = '/live';Also update the external player link:
<a id="openExternal" class="btn ghost" href="https://radio.example.com/live.m3u" rel="noopener">Open in external player</a>Upload files:
sudo mkdir -p /var/www/html/radio
sudo cp index.html /var/www/html/radio/
sudo cp artwork-*.png /var/www/html/radio/
sudo chown -R www-data:www-data /var/www/html/radioIf Icecast doesn't auto-generate live.m3u, create one:
echo "https://radio.example.com/live" | sudo tee /usr/share/icecast2/web/live.m3uIf you prefer not to run your own nginx:
- GitHub Pages → Push to repo, enable Pages
- Netlify → Drag & drop folder
- Cloudflare Pages → Connect Git repo
Note: You'll still need a server for Icecast/Liquidsoap, and you must use index.html.nocache with static hosting.
After installation, test each component:
# 1. Test the picker
sudo -u liquidsoap /usr/local/bin/ls_radio.py pick-next
# Should output a file path
# 2. Check Icecast is running
curl -I http://localhost:8000/live
# Should return 200 OK (or ICY headers)
# 3. Check status JSON
curl http://localhost:8000/status-json.xsl
# Should return JSON metadata
# 4. Check Liquidsoap logs
sudo journalctl -u liquidsoap -n 50 --no-pager
# 5. Test the web player
curl -I https://radio.example.com/radio/
# Should return 200 OK
# 6. Check nginx cache (if using caching)
sudo ls -la /var/cache/nginx/itunes/
# Should show cached files after first artwork lookupThe previous iteration attempted to maintain play history in memory, which led to:
- State loss on restarts
- Race conditions with concurrent requests
- No persistence of separation logic
SQLite provides:
- Persistent state across restarts
- WAL mode for concurrent reads/writes
- Simple file-based storage (no separate DB server)
- Atomic transactions for history tracking
The picker uses a two-pass approach:
- Strict Pass: Sample 2000 random tracks and return the first that doesn't violate separation rules
- Least-Violating Pass: If all tracks violate rules, pick the one that's "least recently violated"
This ensures:
- Fast selection (constant time)
- Good randomness
- Smart handling of small libraries where rules can't always be satisfied
The picker prevents repetition using three rules:
- Artist Separation (
LS_ARTIST_SEP_MIN=45): Same artist won't play again for 45 minutes - Title Separation (
LS_TITLE_SEP_MIN=180): Same song title won't play again for 3 hours - Track Separation (
LS_TRACK_SEP_SEC=0): Same exact file won't play again for X seconds (0=disabled)
How it works:
- When Liquidsoap calls
pick-next, the picker queries the cache - It checks the last play time for artist/title/path against separation windows
- If a track passes all checks, it's selected immediately
- If no tracks pass, it selects the "least violating" (oldest last-play timestamp)
- The selection is stamped in the database
- When the track actually starts on-air,
track-startoverwrites with the precise timestamp
Example: With a 1000-track library and 45-min artist separation:
- If you have 50 unique artists, you'll rarely hit the "least-violating" path
- If you have 5 unique artists, the picker will often choose the artist that's been off-air longest
- One HTML file — no frameworks or dependencies
- Works on desktop, mobile, and tablet
- Lock-screen controls via Media Session API
- Automatic album art via iTunes search
- Smart reconnection handling
- Responsive, mobile-first layout
- Returns immediately even during cache rebuilds
- Background cache refresh for optimal performance
- Configurable artist & title separation windows
- Supports MP3, FLAC, M4A, OGG, WAV, AAC
- Reads metadata via
ffprobe - SQLite-backed play history
- Fast random selection with smart sampling
- Prevents multiple simultaneous cache rescans
- No external dependencies (Python stdlib only)
- Smooth crossfades between tracks
- Loudness normalization (EBU R128-style)
- Gentle compression for consistent dynamics
- Brick-wall limiter to prevent clipping
- Automatic fallback to silence when no tracks available
:root {
--bg: #0b1220; /* Background */
--fg: #e8eefc; /* Text color */
--muted: #9bb0d0; /* Muted text */
--accent: #79a8ff; /* Buttons/links */
--card: #121b30; /* Card background */
}In index.html:
- Update
<title>tag - Change
artist: 'Your Station Name'insetMediaSession()
In stream.liq:
- Update
name="Your Station Name" - Update
url="https://radio.example.com"
Adjust dynamics in stream.liq:
# Louder, more aggressive sound
radio = normalize(radio, target=-14.0, threshold=-20.0, window=0.5)
radio = compress(radio, threshold=-15.0, ratio=4.0, attack=0.005, release=0.2, gain=2.0)
radio = limit(radio, threshold=-0.5, attack=0.002, release=0.1)
# Gentler, more dynamic sound
radio = normalize(radio, target=-18.0, threshold=-24.0, window=0.5)
radio = compress(radio, threshold=-20.0, ratio=2.0, attack=0.02, release=0.4, gain=1.0)
radio = limit(radio, threshold=-1.0, attack=0.005, release=0.2)
For large libraries (>10k tracks):
Environment=LS_ARTIST_SEP_MIN=90
Environment=LS_TITLE_SEP_MIN=360
Environment=LS_TRACK_SEP_SEC=0For small libraries (<500 tracks):
Environment=LS_ARTIST_SEP_MIN=15
Environment=LS_TITLE_SEP_MIN=60
Environment=LS_TRACK_SEP_SEC=0Environment=LS_TOP_N_DIRS=128
Environment=LS_FILES_PER_DIR_TRY=256
Environment=LS_FFPROBE_TIMEOUT_S=1.5
Environment=LS_RESCAN_SEC=172800 # 48 hoursMonitor cache rebuild time:
sudo journalctl -u liquidsoap | grep "cache_builder"Environment=LS_TOP_N_DIRS=32
Environment=LS_FILES_PER_DIR_TRY=64
Environment=LS_ARTIST_SEP_MIN=15 # Lower separation windows
Environment=LS_TITLE_SEP_MIN=60More aggressive settings cause "least violating" picks more often, which is fine for small libraries.
If your music lives on NFS/SMB:
Environment=LS_FFPROBE_TIMEOUT_S=2.0
Environment=LS_RESCAN_SEC=43200 # 12 hours (slower network I/O)The SQLite database will grow over time as play history accumulates. Periodically vacuum it to reclaim space:
sudo -u liquidsoap /usr/local/bin/ls_radio.py vacuumOr set up a weekly cron job:
echo "0 3 * * 0 liquidsoap /usr/local/bin/ls_radio.py vacuum" | sudo crontab -u liquidsoap -Manual database inspection:
sudo sqlite3 /var/lib/liquidsoap/liquidsoap.db
sqlite> SELECT COUNT(*) FROM files;
sqlite> SELECT COUNT(*) FROM last_artist_play;
sqlite> SELECT artist_raw, title_raw, datetime(ts, 'unixepoch') FROM last_artist_play
JOIN files ON last_artist_play.artist_norm = files.artist_norm
ORDER BY ts DESC LIMIT 10;Check Icecast:
sudo systemctl status icecast2
sudo journalctl -u icecast2 -n 50Check Liquidsoap:
sudo systemctl status liquidsoap
sudo journalctl -u liquidsoap -n 50 --no-pagerCommon issues:
- Icecast password mismatch between
stream.liqandicecast.xml - Firewall blocking port 8000
- Liquidsoap can't read music directory
Check music directory permissions:
sudo -u liquidsoap ls /srv/musicTest the picker manually:
sudo -u liquidsoap /usr/local/bin/ls_radio.py pick-next
# Should output a file path, not empty stringCheck database:
sudo -u liquidsoap sqlite3 /var/lib/liquidsoap/liquidsoap.db "SELECT COUNT(*) FROM files;"
# Should be > 0 after cache buildRebuild cache manually:
sudo -u liquidsoap /usr/local/bin/ls_radio.py rebuild-cacheCheck CORS headers:
curl -I https://radio.example.com/live | grep -i access-control
# Should show: Access-Control-Allow-Origin: *Check browser console (F12 → Console):
- Mixed content errors? (HTTP stream on HTTPS page)
- CORS errors? (nginx config missing)
- 404 on stream URL? (wrong URL in index.html)
Verify stream is actually running:
curl -I http://localhost:8000/live
# Should return ICY headers or 200 OKCheck status endpoint:
curl http://localhost:8000/status-json.xsl | jq .
# Should return valid JSONCheck CORS on status endpoint:
curl -I https://radio.example.com/status-json.xsl | grep -i access-controlCheck mount point name:
- Must match in
stream.liq(mount="/live") - Must match in
index.html(TARGET_MOUNT = '/live') - Must match in nginx (
location = /live)
Check nginx cache:
sudo ls -la /var/cache/nginx/itunes/
# Should show cache files after first artwork lookupCheck browser console:
- 404 on
/itunes/search? nginx config missing - 404 on
/itunes/art? nginx config missing - No errors but still broken? iTunes API rate limiting
Test iTunes proxy manually:
curl "https://radio.example.com/itunes/search?term=test&entity=song&limit=1"
# Should return iTunes API JSONCheck library size vs separation windows:
# How many tracks?
sudo -u liquidsoap sqlite3 /var/lib/liquidsoap/liquidsoap.db "SELECT COUNT(*) FROM files;"
# How many unique artists?
sudo -u liquidsoap sqlite3 /var/lib/liquidsoap/liquidsoap.db "SELECT COUNT(DISTINCT artist_norm) FROM files;"If you have 5 artists and 45-min artist separation, you'll hear repeats quickly.
Check database has history:
sudo sqlite3 /var/lib/liquidsoap/liquidsoap.db "SELECT COUNT(*) FROM last_artist_play;"
# Should be > 0 after first few tracksSolution: Lower separation windows or increase library size.
Monitor rebuild progress:
sudo journalctl -u liquidsoap -f
# Watch for messages about cache buildingCheck disk I/O:
sudo apt install sysstat
iostat -x 5Solutions:
- Increase
LS_RESCAN_SECto reduce rebuild frequency - Reduce library size or split into multiple directories
- Increase
LS_FFPROBE_TIMEOUT_Sif network-mounted
# Check music directory permissions
sudo -u liquidsoap ls -la /srv/music
# Verify database directory exists
ls -la /var/lib/liquidsoap
# Check Icecast password matches in stream.liq
grep "password=" /etc/liquidsoap/stream.liq
# Test Liquidsoap config syntax
liquidsoap --check /etc/liquidsoap/stream.liq- Mixed content: Browser blocks HTTP streams on HTTPS pages
- Solution: Use nginx to proxy the stream over HTTPS
- Wrong stream URL: Check
STREAM_URLinindex.html - Icecast not streaming:
curl -I http://localhost:8000/live
# Check cache directory exists and is writable
ls -la /var/cache/nginx/itunes/
sudo chown -R www-data:www-data /var/cache/nginx
# Check cache is enabled in config
grep "proxy_cache_path" /etc/nginx/nginx.conf
# Test cache headers
curl -I "https://radio.example.com/itunes/search?term=test&entity=song&limit=1"
# Should show: X-Cache-Status: MISS (first time) or HIT (cached)radioplayer/
├── index.html # Web player (with nginx c
├── artwork-512.png # Fallback album art
├── artwork-192.png
├── artwork-096.png
├─ls_radio.py # Track selector script
└── config_examples/
├── stream.liq # Liquidsoap config
├── icecast.xml # Icecast config
├── nginx # nginx config
└── liquidsoap.service # systemd service
:root {
--bg: #0b1220; /* Background */
--fg: #e8eefc; /* Text color */
--muted: #9bb0d0; /* Muted text */
--accent: #79a8ff; /* Buttons/links */
}- In
index.html:- Update
<title>tag - Change
artist: 'Your Station Name'insetMediaSession()
- Update
- In
stream.liq:- Update
name="Your Station Name"
- Update
Adjust dynamics in stream.liq:
radio = normalize(radio)
radio = compress(radio, threshold=-12.0, ratio=3.0, attack=0.005, release=0.2, gain=0.0)
radio = limit(radio, threshold=-1.0, attack=0.002, release=0.2)
- Check Icecast password matches in both configs
- Verify firewall allows port 8000
- Check logs:
sudo journalctl -u liquidsoap -f - Verify Icecast is running:
sudo systemctl status icecast2
- Verify
LS_MUSIC_DIRpath is correct and contains audio files - Check file permissions:
ls -la /srv/music - Test picker manually:
python3 /usr/local/bin/ls_radio.py - Check Liquidsoap service (sudo journalctl -xeu liquidsoap) logs for errors
- Check CORS headers in nginx config
- Verify stream URL is accessible:
curl -I https://radio.example.com/live - Check browser console for JavaScript errors
- Ensure HTTPS is working (mixed content blocks HTTP streams)
- Increase
LS_RESCAN_SECto reduce rebuild frequency (default: 24 hours) - Reduce library size or split into multiple directories
- Check disk I/O performance with
iostat