Skip to content

jaketremper/radioplayer

Repository files navigation

Radio Player - Simple Internet Radio Station Setup Guide

A minimal, single-file radio player powered by Liquidsoap and Icecast.
No CMS. No app. Just your music, a stream, and a simple web player.


Overview

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

Data Flow

Music Files → Liquidsoap (via ls_radio.py)
             ↓
         Icecast Server
             ↓
          nginx (proxy)
             ↓
          Listeners

Metadata Flow:
Icecast status-json.xslindex.html → artwork & now-playing info.

All three services can run on a single VPS or be separated for scalability.


Quick Start

1. Install Dependencies

On Transcoder (Liquidsoap Host, Ubuntu 22.04/24.04)

sudo apt update
sudo apt install liquidsoap python3 ffmpeg sqlite3

Note: ls_radio.py uses only Python stdlib - no pip packages required.

On Icecast Server

sudo apt install icecast2

On Web Server

sudo apt install nginx
# Or use any static hosting: GitHub Pages, Netlify, Cloudflare Pages, etc.

2. Configure Icecast

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 icecast2

Verify it's running:

sudo systemctl status icecast2
curl -I http://localhost:8000/

3. Set Up the Track Selector

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

Create 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 10 -q:a 9 -acodec libmp3lame /usr/share/liquidsoap/_silence.mp3

The picker outputs a file path to stdout. Liquidsoap reads that path to request the next song.

First Run Behavior

On first run with an empty cache:

  1. pick-next returns a random track immediately (via quick_random_dart())
  2. A background process forks to build the full cache
  3. Subsequent picks use the cache for better separation logic
  4. Cache rebuilds automatically every 24 hours (configurable via LS_RESCAN_SEC)

4. Configure Liquidsoap

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 init

Pre-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-cache

Create /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 -R liquidsoap:liquidsoap /etc/liquidsoap
sudo chmod 0700 /etc/liquidsoap
sudo chmod 0600 /etc/liquidsoap/stream.liq

Create 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

# Scheduled content (optional — see "Scheduled Content" section below)
# Environment=LS_EVERGREEN_DIR=/var/lib/liquidsoap/evergreen
# Environment=LS_SLOT_PRE_SEC=150
# Environment=LS_SLOT_POST_SEC=150

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

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable liquidsoap
sudo systemctl start liquidsoap

Optional sanity check:

liquidsoap --check /etc/liquidsoap/stream.liq

5. Configure nginx

Two versions available:

  • index.html - Requires nginx caching proxy (recommended for production)
  • index.html.nocache - Direct iTunes API calls (simpler setup, may hit rate limits)

Option A: With nginx Caching (Recommended)

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 9.9.9.9 149.112.112.112 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 nginx

Option B: Without nginx Caching (Simple Setup)

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


6. Deploy Web Player

Create Fallback Artwork

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

Configure and Deploy

Edit 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/radio

Create M3U Playlist (Optional)

If Icecast doesn't auto-generate live.m3u, create one:

echo "https://radio.example.com/live" | sudo tee /usr/share/icecast2/web/live.m3u

Static Hosting Alternatives

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


Scheduled Content (Station IDs, Drops, Sweepers)

ls_radio.py supports automatic injection of scheduled audio content near each quarter-hour boundary (:00, :15, :30, :45). This is useful for station IDs, drops, movie quotes, PSAs, or any short audio you want played periodically.

How It Works

When pick-next is called and the current time falls within a configurable window around a quarter-hour boundary, the picker returns a randomly selected file from LS_EVERGREEN_DIR instead of a music track. After one clip is served for a given slot, the picker returns to normal music selection for the remainder of the window — so you always get exactly one clip per quarter hour, played after the current song finishes naturally.

Default windows (with LS_SLOT_PRE_SEC=150 and LS_SLOT_POST_SEC=150):

Slot Window
:00 57:30 – 02:30
:15 12:30 – 17:30
:30 27:30 – 32:30
:45 42:30 – 47:30

If a long track is still playing when the window opens, the clip fires as soon as that track ends — no interruptions, no hard cuts.

If LS_EVERGREEN_DIR is not set, empty, or the directory doesn't exist, the feature is silently disabled and music plays uninterrupted.

Setup

Create the evergreen directory and drop your audio files in:

sudo mkdir -p /var/lib/liquidsoap/evergreen
sudo chown liquidsoap:liquidsoap /var/lib/liquidsoap/evergreen
# Copy your clips — WAV, MP3, FLAC all work
sudo cp /path/to/your/clips/*.wav /var/lib/liquidsoap/evergreen/

Enable in your systemd unit by uncommenting these lines:

Environment=LS_EVERGREEN_DIR=/var/lib/liquidsoap/evergreen
Environment=LS_SLOT_PRE_SEC=150
Environment=LS_SLOT_POST_SEC=150

Then reload and restart:

sudo systemctl daemon-reload
sudo systemctl restart liquidsoap

No changes to stream.liq are required.

Tips for Clip Selection

  • Self-contained clips work best. Content that lands without setup or context — a memorable line, a sound effect, a short piece of music — works much better than something that's only funny as part of a longer scene.
  • Normalize your clips to a consistent loudness before dropping them in. Liquidsoap's normalization chain processes them, but pre-normalized clips give more predictable results.
  • Any length works, but clips under 30 seconds feel most natural as breaks. Longer clips are fine too.
  • New files are picked up automatically — no restart needed. The evergreen directory is scanned on each pick.

Tuning the Window

The pre/post window controls how late a track can end and still trigger a clip. Wider windows catch more track endings but increase the chance a clip fires noticeably late:

# Tighter window — clips fire closer to the boundary, but long tracks may miss
Environment=LS_SLOT_PRE_SEC=90
Environment=LS_SLOT_POST_SEC=90

# Wider window — more forgiving for long tracks, clips may fire up to 5 min late
Environment=LS_SLOT_PRE_SEC=300
Environment=LS_SLOT_POST_SEC=300

Verify Your Setup

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 -fu liquidsoap

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

How It Works

Architecture Decisions

Why SQLite?

The 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

Track Selection Algorithm

The picker uses a two-pass approach:

  1. Strict Pass: Sample 2000 random tracks and return the first that doesn't violate separation rules
  2. 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

Separation Logic

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:

  1. When Liquidsoap calls pick-next, the picker queries the cache
  2. It checks the last play time for artist/title/path against separation windows
  3. If a track passes all checks, it's selected immediately
  4. If no tracks pass, it selects the "least violating" (oldest last-play timestamp)
  5. The selection is stamped in the database
  6. When the track actually starts on-air, track-start overwrites with the precise timestamp

Features

Web Player

  • 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

Track Selector (ls_radio.py)

  • 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
  • Scheduled content injection at quarter-hour boundaries
  • No external dependencies (Python stdlib only)

Audio Processing

  • 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

Customization

Colors (in index.html)

:root {
  --bg: #0b1220;      /* Background */
  --fg: #e8eefc;      /* Text color */
  --muted: #9bb0d0;   /* Muted text */
  --accent: #79a8ff;  /* Buttons/links */
  --card: #121b30;    /* Card background */
}

Station Name

In index.html:

  • Update <title> tag
  • Change artist: 'Your Station Name' in setMediaSession()

In stream.liq:

  • Update name="Your Station Name"
  • Update url="https://radio.example.com"

Audio Processing

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)

Separation Windows

For large libraries (>10k tracks):

Environment=LS_ARTIST_SEP_MIN=90
Environment=LS_TITLE_SEP_MIN=360
Environment=LS_TRACK_SEP_SEC=0

For small libraries (<500 tracks):

Environment=LS_ARTIST_SEP_MIN=15
Environment=LS_TITLE_SEP_MIN=60
Environment=LS_TRACK_SEP_SEC=0

Performance Tuning

For Large Libraries (>50k tracks)

Environment=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 hours

For Small Libraries (<1000 tracks)

Environment=LS_TOP_N_DIRS=32
Environment=LS_FILES_PER_DIR_TRY=64
Environment=LS_ARTIST_SEP_MIN=15
Environment=LS_TITLE_SEP_MIN=60

For Network-Mounted Music

If your music lives on NFS/SMB:

Environment=LS_FFPROBE_TIMEOUT_S=2.0
Environment=LS_RESCAN_SEC=43200  # 12 hours

Database Maintenance

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 vacuum

Or 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;

Troubleshooting

Stream won't start

Check Icecast:

sudo systemctl status icecast2
sudo journalctl -u icecast2 -n 50

Check Liquidsoap:

sudo systemctl status liquidsoap
sudo journalctl -fu liquidsoap

Common issues:

  • Icecast password mismatch between stream.liq and icecast.xml
  • Firewall blocking port 8000
  • Liquidsoap can't read music directory

No songs playing

Check music directory permissions:

sudo -u liquidsoap ls /srv/music

Test the picker manually:

sudo -u liquidsoap /usr/local/bin/ls_radio.py pick-next
# Should output a file path, not empty string

Check database:

sudo -u liquidsoap sqlite3 /var/lib/liquidsoap/liquidsoap.db "SELECT COUNT(*) FROM files;"
# Should be > 0 after cache build

Rebuild cache manually:

sudo -u liquidsoap /usr/local/bin/ls_radio.py rebuild-cache

Scheduled clips not firing

Check the evergreen directory:

ls -la /var/lib/liquidsoap/evergreen/
# Should contain audio files

Check the environment variable is set:

sudo systemctl show liquidsoap | grep EVERGREEN

Check which slot the picker thinks it's in:

python3 -c "
import time
SLOT_PRE_SEC = 150
SLOT_POST_SEC = 150
t = time.localtime()
total_secs = t.tm_min * 60 + t.tm_sec
print(f'Current time: {t.tm_hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d}')
print(f'Seconds into hour: {total_secs}')
for i, m in enumerate([0,15,30,45]):
    slot_secs = m * 60
    if m == 0:
        print(f'Slot :00 — pre: {3600-total_secs}s away, post: {total_secs}s past')
    else:
        diff = total_secs - slot_secs
        print(f'Slot :{m:02d} — diff: {diff}s (window: -{SLOT_PRE_SEC} to +{SLOT_POST_SEC})')
"

Verify a clip was served for the last slot:

sudo sqlite3 /var/lib/liquidsoap/liquidsoap.db \
  "SELECT slot_id, datetime(ts, 'unixepoch', 'localtime') FROM evergreen_played ORDER BY ts DESC LIMIT 10;"

Player shows "connecting" forever

Check CORS headers:

curl -I https://radio.example.com/live | grep -i access-control

Check browser console (F12 → Console) for mixed content or CORS errors.

Verify stream is running:

curl -I http://localhost:8000/live

Metadata not updating

Check status endpoint:

curl http://localhost:8000/status-json.xsl | jq .

Check mount point name matches in stream.liq, index.html, and nginx config.

Artwork not loading

Test iTunes proxy manually:

curl "https://radio.example.com/itunes/search?term=test&entity=song&limit=1"

Check nginx cache:

sudo ls -la /var/cache/nginx/itunes/

Same songs keep repeating

Check library size vs separation windows:

sudo -u liquidsoap sqlite3 /var/lib/liquidsoap/liquidsoap.db \
  "SELECT COUNT(*) as tracks, COUNT(DISTINCT artist_norm) as artists FROM files;"

If you have few artists and high separation windows, lower LS_ARTIST_SEP_MIN.


Common Pitfalls

Liquidsoap won't start

# 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

Player loads but no audio

  • Mixed content: Browser blocks HTTP streams on HTTPS pages — use nginx to proxy over HTTPS
  • Wrong stream URL: Check STREAM_URL in index.html
  • Icecast not streaming: curl -I http://localhost:8000/live

nginx cache not working

sudo ls -la /var/cache/nginx/itunes/
sudo chown -R www-data:www-data /var/cache/nginx
grep "proxy_cache_path" /etc/nginx/nginx.conf

File Structure

radioplayer/
├── index.html              # Web player (with nginx caching)
├── index.html.nocache      # Web player (direct iTunes API)
├── 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