Skip to main content
Version: 2.5.x (dev)

Icecast Server Emulation

Liquidsoap can act as an icecast-compatible server, accepting source client connections using the icecast.server operator. This allows you to receive streams from software like butt, mixxx, or any other icecast-compatible source client.

Experimental Feature

This functionality is still experimental. While it works for many common use cases, some features may change in future releases and some icecast configuration options are not yet supported.

Basic Usage

The simplest way to start an icecast-compatible server:

icecast-server-basic.liq
server = icecast.server()

# React when a source connects
server.on_source_connect(
fun (r) ->
log.important(
"Source connected on mount #{r.mount}"
)
)

# React when a source disconnects
server.on_source_disconnect(
fun (r) ->
log.important(
"Source disconnected from mount #{r.mount}"
)
)

This starts a server on port 8000 with the default password "hackme".

Parameters

  • port: Port to listen on (default: 8000)
  • password: Source password for authentication (default: "hackme")

Parameters passed explicitly take precedence over the values of a configuration file.

  • config: Optional path to an icecast XML configuration file
  • dedicated_encoder: Allocate one encoder per listener (see below)
  • serve: Enable or disable the built-in status page (default: true, see below)
  • serve_auth: Optional callback (request) -> bool for access control on status endpoints
  • serve_json: Optional callback (stats) -> string to replace the built-in JSON renderer
  • serve_html: Optional callback (stats) -> string to replace the built-in HTML renderer
  • x_forwarded_for_proxy_ips: List of known reverse-proxy IPs for real-IP extraction (see below)
  • x_forwarded_for: Advanced callback to fully override real-IP extraction logic (see below)
  • format_options: Optional callback (string) -> [(string * string)] that returns muxer options for a given container format name. When null, falls back to settings.icecast.server.default_muxer_options (see below)
  • ip_hash: Function applied to every listener IP before it is exposed anywhere (see below)
  • access_log: Path of an icecast-style access log, - for standard error (see below)
  • playlist_log: Path of an icecast-style playlist log, - for standard error
  • admin_user: User name for the admin listener page (default: "admin")
  • admin_password: Password for the admin listener page, which is disabled without one (see below)

Return Value

The icecast.server function returns a record with the following methods:

  • mounts(): Returns a list of currently active mount points
  • get_source(mount): Returns the source for a given mount point
  • get_config(mount): Returns the current {format, streams} record for a mount, or null if the mount is not active
  • stats(): Returns the current mount stats list (see below for its fields)
  • on_source_connect(handler): Register a handler called when a source connects
  • on_source_disconnect(handler): Register a handler called when a source disconnects
  • on_listener_connect(handler): Register a handler called when a listener connects (see below)
  • on_listener_disconnect(handler): Register a handler called when a listener disconnects
  • on_metadata(handler): Register a handler called when a mount's metadata changes

Every on_* method takes an optional synchronous argument, false by default, in which case the handler runs in its own thread.

Listener Callbacks

Knowing who listens, for how long, and what they were listening to is what most station tooling is built on: listener statistics, royalty reports, dashboards or a Prometheus exporter. icecast.server reports every listener session through callbacks, so you can feed that data wherever you need it:

icecast-server-listener-callbacks.liq
server = icecast.server(password="hackme")

server.on_listener_connect(
fun (listener) ->
log.important(
"Listener #{listener.id} (#{listener.ip}) tuned in to #{listener.mount}"
)
)

server.on_listener_disconnect(
fun (listener) ->
log.important(
"Listener #{listener.id} left #{listener.mount} after #{
int(listener.duration)
}s and #{listener.bytes_sent} bytes"
)
)

server.on_metadata(
fun (update) ->
log.important(
"Now playing on #{update.mount} for #{update.listeners} listener(s): #{
update.metadata["title"]
}"
)
)

Listener handlers receive a record with the following fields:

FieldTypeDescription
idintConnection identifier, unique per mount output
mountstringMount the listener is connected to
ipstringListener address after X-Forwarded-For resolution, hashed by default
user_agentstring?User-Agent request header
refererstring?Referer request header
uristringRequested path
protocolstringHTTP protocol version, e.g. "1.1"
connected_atfloatConnection time, in seconds since the epoch
durationfloatSeconds connected: 0. on connect, the session length on disconnect
bytes_sentintBytes sent, HTTP response headers included: 0 on connect, total on disconnect

Raw request headers are not passed on: they can carry the listener's address, for instance in X-Forwarded-For.

Synchronous disconnect handlers are called exactly once per listener, and always after its synchronous connect handlers. Asynchronous handlers each run in their own thread, so that order is not guaranteed for them.

on_metadata handlers receive a record with the mount, its new metadata and the current number of listeners.

Access and Playlist Logs

Many log analyzers and statistics tools read the access log written by icecast. icecast.server can write the same log, so these tools keep working when liquidsoap takes over serving listeners:

icecast-server-access-log.liq
icecast.server(
password="hackme",
access_log="/var/log/liquidsoap/access.log",
playlist_log="/var/log/liquidsoap/playlist.log",
admin_password="admin_secret"
)

When an icecast configuration file is used, the logs follow its <logging> section instead, see the configuration reference.

Access log

One line is written when a listener disconnects, in icecast's variant of the combined log format, with the session length in seconds appended:

8c2f0e7a1d4b9f36 - - [14/Sep/2026:21:40:58 +0200] "GET /live HTTP/1.1" 200 1843200 "-" "VLC/3.0.20 LibVLC/3.0.20" 115

The fields are the listener IP (hashed by default), identity and user (always -), disconnection time, request, status code, bytes sent including the HTTP response headers, referer, user agent and duration. As in icecast, control characters, spaces, !, ", ` and \ are written as \xHH escapes, except that spaces are kept in the referer and user agent. Bytes above 0x7f are written as-is, so UTF-8 text stays readable.

Some tools validate the first field as an IP address and reject hashed values. For instance, GoAccess needs --no-ip-validation. You can also log plain IPs.

Unlike icecast, only listener sessions are logged: source client connections and requests to the status pages are not.

Playlist log

One line is written each time a mount's metadata changes:

14/Sep/2026:21:40:58 +0200|/live|12|Artist - Title

The fields are the time, mount, number of listeners and the artist - title text. Icecast writes the text as-is. Liquidsoap turns | characters and line breaks into spaces, so that each update stays on one parseable line.

Rotation

Log files are reopened for every line, so external tools like logrotate can move them at any time without signalling liquidsoap. Liquidsoap also rotates them the way icecast does: once a file grows past <logsize>, it is renamed to <file>.old, or to <file>.YYYYmmdd_HHMMSS when <logarchive> is set. Without <logsize>, the limit is 1GB.

Listener Privacy

A listener's IP address is personal data. By default, icecast.server replaces it with a hash everywhere it is exposed: callbacks, stats(), the admin page and the logs. The hash is a 16-character MD5 digest of the address. It is the same on every run, so a listener can be followed across connections and restarts, for instance to count unique listeners.

This is a surface-level protection: it keeps addresses out of logs and dashboards. The key used by the default hash is public, so anyone can hash a guessed address and compare. If this matters to you, pass your own function with the ip_hash parameter, for instance with a secret key:

icecast-server-ip-hash.liq
# The key must stay secret: whoever holds it can check guessed addresses.
icecast.server(
password="hackme",
ip_hash=fun (ip) -> string.digest("my-secret-key" ^ ip)
)

If you need the actual addresses and are allowed to keep them, you have to opt in explicitly:

icecast-server-plain-ips.liq
icecast.server(password="hackme", ip_hash=fun (ip) -> ip)

Keep in mind that some data still contains raw addresses:

  • Liquidsoap's own log mentions each listener's ip:port at level 4 (info) and above.
  • The x_forwarded_for callback sees the raw address and headers, since it runs before hashing.

dedicated_encoder

By default, all listeners on a mount share a single encoder instance. Setting dedicated_encoder=true allocates one independent encoder per listener.

With copy-based encoders (e.g. copy_encoder("matroska") or %ffmpeg with %audio.copy / %video.copy), this amounts to a lightweight remux per listener. The overhead is low — essentially one mux pass per listener — while the benefit is significant: each listener receives a clean, self-contained stream starting from a proper frame boundary.

icecast-server-dedicated-encoder.liq
icecast.server(port=8000, password="hackme", dedicated_encoder=true)

Each listener that connects will get their own stream, properly initialised from a clean frame boundary, regardless of when they join.

Note: With full re-encoding (e.g. %mp3, %aac), dedicated_encoder=true creates a complete encoder per listener, which can be costly under load. Prefer copy-based encoders when using dedicated_encoder.

Reverse Proxy and X-Forwarded-For

When icecast.server runs behind a reverse proxy (e.g. nginx), the listener IP recorded in stats will be the proxy's IP rather than the real client IP. Use x_forwarded_for_proxy_ips to fix this.

x_forwarded_for_proxy_ips takes a list of known proxy IPs. The default implementation (icecast.server.x_forwarded_for) walks the X-Forwarded-For header from right to left, skips any IP in the list, and returns the first non-proxy IP. This is safe against client spoofing: even if a client sends a forged X-Forwarded-For header, the proxy appends the real connecting IP as the rightmost entry, which is what gets used.

# Single nginx proxy at 10.0.0.1
icecast.server(
x_forwarded_for_proxy_ips=["10.0.0.1"],
password="hackme"
)

# CDN + nginx in front
icecast.server(
x_forwarded_for_proxy_ips=["10.0.0.1", "203.0.113.42"],
password="hackme"
)

For advanced use cases — custom header names, CIDR matching, CDN-specific headers like CF-Connecting-IP — pass a full callback via x_forwarded_for. The callback receives a record with ip (connection IP), headers, protocol, uri, and proxy_ips (from x_forwarded_for_proxy_ips, or [] if not set), and must return a string IP:

# Trust Cloudflare's CF-Connecting-IP header
icecast.server(
x_forwarded_for=fun ({ip, headers, ...}) ->
list.assoc(default=ip, "cf-connecting-ip", headers),
password="hackme"
)

Status Page

By default, icecast.server registers two HTTP endpoints on the icecast port:

  • / — an HTML status page showing active mounts, listener counts, and playback controls
  • /status.json — a JSON endpoint with the same data, polled by the HTML page every 5 seconds

Disabling the Status Page

Pass serve=false to disable both endpoints:

icecast.server(serve=false, password="hackme")

Access Control

Use serve_auth to gate both endpoints behind a check. The callback receives the HTTP request and must return true to allow access:

icecast-server-serve-auth.liq
icecast.server(
serve_auth=fun (req) -> req.headers["x-secret"] == "my-secret-token"
)

Any request that fails the check receives a 401 Unauthorized response with a WWW-Authenticate header.

Admin Listener Page

The public status page only shows listener counts. For operators, icecast.server also serves a listener page protected by HTTP Basic authentication:

  • /admin/listeners — an HTML page listing each mount's listeners, with their (hashed) IP, user agent, connection time and bytes sent, plus the mount's peak listeners, connection count and total listening time
  • /admin/listeners.json — the same data as JSON

The page is enabled by setting an admin password, either with the admin_password parameter or with <admin-password> in the configuration file. The user name defaults to admin. Without a password, the page is not registered at all. Like the status page, it is disabled by serve=false.

Custom JSON Renderer

Use serve_json to replace the built-in /status.json output. The callback receives the stats list and must return a JSON string. The example below also disables the HTML page by returning a plain not-found response from serve_html:

icecast-server-serve-json.liq
icecast.server(
serve_html=fun (_) ->
"<!DOCTYPE html><html><body>Not found.</body></html>",
serve_json=fun (stats) ->
begin
mounts =
list.map(
fun ((mount, s)) ->
{
mount = mount,
name = s.name,
listeners = list.length(s.listeners)
},
stats
)
json.stringify(compact=true, {mounts = mounts})
end
)

The stats list passed to both serve_json and serve_html is a list of (mount, stats) pairs where each stats record contains:

  • name, content_type, mime_type: information about the mount's stream
  • started: when the current source connected
  • listeners: the connected listeners, as listener records
  • peak_listeners: the largest number of simultaneous listeners
  • connections: the number of listener connections so far
  • listening_time, bytes_sent: totals over finished listener sessions
  • current_metadata: the mount's latest metadata

Listener records contain hashed IPs unless ip_hash says otherwise. Keep that in mind before publishing them from a custom renderer.

Live Streaming Muxer Options

Some container formats require specific muxer flags to produce a valid live stream. For example, Matroska and WebM streams need their muxer told that there will be no seekable index at the end.

icecast.server automatically applies these options when remuxing an incoming stream via copy_encoder. The defaults are controlled by settings.icecast.server.default_muxer_options, which maps container format names to lists of FFmpeg muxer options:

FormatDefault optionsEffect
matroskadash=1, live=1Disables index/cues, writes streaming-compatible cluster timestamps
webmdash=1, live=1Same as matroska (WebM is a subset)

To override the defaults globally, set the setting before starting the server:

settings.icecast.server.default_muxer_options :=
[("matroska", [("dash", "1"), ("live", "1")]),
("webm", [("dash", "1"), ("live", "1")])]

To override per-server instance, use the format_options parameter. The callback receives the detected container format name and returns the options list to apply:

# Disable all extra muxer options
icecast.server(format_options=fun (_) -> [], password="hackme")

# Custom options for matroska, defaults for everything else
icecast.server(
format_options=fun (fmt) ->
if fmt == "matroska" then [("dash", "1"), ("live", "1"), ("cluster_size_limit", "1000000")]
else list.assoc(default=[], fmt, settings.icecast.server.default_muxer_options())
end,
password="hackme"
)

When format_options is null (the default), settings.icecast.server.default_muxer_options is used. When provided, it fully replaces the settings lookup — the callback is responsible for returning all options for every format.

Key Differences from Icecast

The liquidsoap icecast server operates fundamentally differently from a traditional icecast server. Understanding these differences is important for getting the most out of this feature.

Direct Encoded Content Manipulation

Unlike icecast, which primarily acts as a relay for encoded streams, liquidsoap can directly manipulate the encoded content. The incoming stream is demuxed, passed as encoded packets through the liquidsoap pipeline, and remuxed for output. This allows advanced format manipulation without ever needing to decode and re-encode, avoiding the CPU and memory consumption typically associated with transcoding.

This makes it possible to:

  • Format-compatible fallbacks: When a source disconnects, the fallback mount seamlessly takes over without format incompatibility, because both streams go through the same processing pipeline.

  • Seamless transitions: Listeners are never disconnected during source switches. The transition happens smoothly within the liquidsoap processing chain.

  • Per-listener clean streams: With dedicated_encoder=true and copy encoders, each listener gets a fresh, properly initialised stream, enabling reliable playback of any streamable container format.

Because of this architecture, the following icecast options are fundamentally incompatible:

  • fallback-override: In icecast, this allows a reconnecting source to "steal back" listeners from a fallback mount. In Liquidsoap, fallback is implemented by switching the underlying source inside the same output via source.dynamic. Listeners remain connected to the original mount throughout — there are no listeners "at the fallback mount" to reclaim, so the concept does not apply.

  • fallback-when-full: In icecast, this redirects to a fallback when max-listeners is reached. Liquidsoap's architecture handles this differently through its own source management.

Using an Icecast Configuration File

You can use a standard icecast XML configuration file:

icecast-server-config.liq
icecast.server(config="/etc/icecast.xml")

This parses the configuration file and extracts supported settings. See the Configuration Reference section below for complete details on what is supported.

Example Configuration File

<icecast>
<limits>
<sources>10</sources>
</limits>

<authentication>
<source-password>mysecretpassword</source-password>
</authentication>

<listen-socket>
<port>8000</port>
</listen-socket>

<http-headers>
<header name="Access-Control-Allow-Origin" value="*" />
</http-headers>

<!-- Default settings for all mounts -->
<mount type="default">
<burst-size>65536</burst-size>
</mount>

<!-- Specific mount configuration -->
<mount type="normal">
<mount-name>/live.mp3</mount-name>
<username>dj</username>
<password>djpassword</password>
<max-listeners>100</max-listeners>
<fallback-mount>/fallback.mp3</fallback-mount>
<on-connect>/path/to/script.sh</on-connect>
<on-disconnect>/path/to/script.sh</on-disconnect>
</mount>
</icecast>

Configuration Reference

This section provides a comprehensive reference for icecast XML configuration options, indicating which are supported, which are not yet implemented, and which will likely never be supported due to architectural differences.

listen-socket

OptionStatusNotes
portSupportedServer listening port
bind-addressSupportedSets settings.harbor.bind_addrs
tls / sslSupportedIcecast 2.4: 0/1. Icecast 2.5: disabled/auto_no_plain. Other 2.5 values log a warning and disable TLS.
shoutcast-mountNot implementedShoutcast compatibility mount

Note: Only the first listen-socket entry is used. Multiple listen sockets are not supported; a warning is logged if more than one is found.

authentication

OptionStatusNotes
source-passwordSupportedGlobal password for source clients
admin-userSupportedUser name for the admin listener page
admin-passwordSupportedPassword enabling the admin listener page

limits

OptionStatusNotes
sourcesSupportedMaximum number of simultaneous source connections
clientsParsed (TODO)Maximum number of listeners (not yet enforced)
source-timeoutSupportedTimeout in seconds for source connections (float)
client-timeoutSupportedTimeout in seconds for client connections (float)
burst-sizeSupportedDefault burst size for new listeners (bytes)
burst-on-connectSupportedSet to 0 to disable burst entirely
queue-sizeNot usedLiquidsoap manages queues differently
header-timeoutNot usedLiquidsoap handles headers differently

paths

OptionStatusNotes
basedirNot implemented
logdirSupportedDirectory of the log files set in <logging>
pidfileNot supported
tls-certificate / ssl-certificateSupportedPath to TLS certificate file (required when TLS is enabled). May include the private key.
tls-keySupportedPath to separate TLS private key file (icecast 2.5 only)
webrootNot implementedNo built-in web interface
adminrootNot implementedNo built-in admin interface
allow-ipNot implementedUse a reverse proxy (nginx) or firewall instead
deny-ipNot implementedUse a reverse proxy (nginx) or firewall instead
ssl-allowed-ciphersNot implementedTLS cipher configuration not exposed
aliasNot implementedURL aliasing not supported

logging

Log files are created in <paths><logdir>. Without a logdir, only the - value (console output) takes effect.

OptionStatusNotes
accesslogSupportedAccess log file, access.log by default. - writes to standard error. See above
errorlogSupportedLiquidsoap's own log file, error.log by default. - logs to the console only
playlistlogSupportedPlaylist log file, disabled by default. See above
loglevelSupported1/error to 4/debug, mapped to liquidsoap log levels 2 to 5
logsizeSupportedSize in KiB past which access and playlist logs are rotated, 1GB by default
logarchiveSupportedWhen 1, rotated logs keep a timestamped name instead of replacing <file>.old
memorybacklogNot implementedIcecast's in-memory log view is not available

The accesslog and playlistlog settings are overridden by the access_log and playlist_log parameters.

http-headers

Global HTTP headers are fully supported. Use the standard icecast format:

<http-headers>
<header name="Access-Control-Allow-Origin" value="*" />
<header name="X-Custom-Header" value="value" />
</http-headers>

mount

Mount configurations support both type="default" (settings applied to all mounts) and type="normal" (specific mount configurations).

Supported Mount Options

OptionDescription
mount-nameMount point path (e.g., /live.mp3)
usernameSource client username (default: "source")
passwordMount-specific password (overrides global)
dump-filePath to dump the raw stream to a file
burst-sizeInitial burst size for new listeners (bytes)
fallback-mountMount to fall back to when this source disconnects
on-connectShell command to execute when source connects
on-disconnectShell command to execute when source disconnects
http-headersCustom HTTP headers for this mount's responses

Unsupported Mount Options

These options are not yet implemented:

OptionReason
max-listenersNot yet implemented
hiddenNot yet implemented
publicDirectory listing registration not implemented
introIntro file playback not implemented
max-listener-durationListener duration limits not implemented
authenticationURL-based authentication not implemented

Incompatible Mount Options

These options are fundamentally incompatible with liquidsoap's architecture.

OptionReason
fallback-overrideLiquidsoap manages sources through its own pipeline; "stealing back" listeners doesn't apply
fallback-when-fullLiquidsoap handles listener limits through its own source management

Configuration Sections Not Supported

The following icecast configuration sections are not supported and will be ignored:

  • fileserve - Static file serving (use liquidsoap's harbor HTTP handlers)
  • relay - Stream relaying (use liquidsoap's input.http instead)
  • directory - Directory listings (YP)
  • security - Use liquidsoap's security settings

Complete Example

Here's a complete example showing a radio station setup with multiple mounts and fallback handling:

icecast-server-complete.liq
# Start the icecast-compatible server
server =
icecast.server(
port=8000,
password="source_password",
config="/etc/liquidsoap/icecast.xml"
)

# Create a fallback source (e.g., playlist)
fallback_source = playlist("/path/to/music")

# When a DJ connects, log it and optionally do something
server.on_source_connect(
fun (r) ->
begin
log.important(
"DJ connected on #{r.mount}"
)
# You could trigger notifications, update a database, etc.
end
)

server.on_source_disconnect(
fun (r) ->
log.important(
"DJ disconnected from #{r.mount}"
)
)

# Output the stream
# The server handles incoming connections automatically
# and creates output.harbor instances for each mount

Minimal Configuration File

<icecast>
<authentication>
<source-password>changeme</source-password>
</authentication>

<listen-socket>
<port>8000</port>
</listen-socket>

<mount type="default">
<burst-size>65536</burst-size>
<fallback-mount>/fallback</fallback-mount>
</mount>

<mount type="normal">
<mount-name>/live</mount-name>
</mount>
</icecast>

See Also