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. You can use it to receive streams from butt, Mixxx or any other icecast-compatible source client. To send your stream to an external Icecast server, see streaming to Icecast and Shoutcast.

Experimental Feature​

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

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")
  • name: Namespace of the server's telnet commands (default: "icecast.server")
  • 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)

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

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
  • reload(): Read the configuration file again and apply it (see below)

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.

Log Files​

Many log analyzers and statistics tools read the logs written by icecast. icecast.server writes the same access, playlist and error logs, 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. The error log is only set up there.

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.

Error log​

The error log records the server's own events, such as sources logging in and out, rejected source connections and configuration reloads. It is a file of its own and does not change liquidsoap's log. Lines follow icecast's format, local time, level, category/function and message:

[2026-09-25 15:17:57] INFO connection-handle/_handle_source_request Source logging in at mountpoint "/live"

Levels are EROR, WARN, INFO and DBUG, and <loglevel> sets the most verbose one written, INFO by default. Categories and messages reuse icecast's where there is an equivalent, so existing log parsers keep matching. Every event is also written to liquidsoap's log.

Rotation​

Log files are kept open, as in icecast. After an external tool like logrotate moves one, send SIGHUP or SIGUSR1 to reopen it at its configured path, for instance with postrotate kill -HUP <pid>, which is also what icecast's packages do. SIGUSR1 reopens liquidsoap's own log file too. logrotate's copytruncate option works without any signal.

Liquidsoap also rotates the logs 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), each listener gets its own remuxer. A remux costs little CPU, and each listener receives a self-contained stream that starts on a frame boundary, with its own container header.

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

Each listener that connects gets their own stream, starting from a frame boundary at the time they join.

With full re-encoding (e.g. %mp3, %aac), dedicated_encoder=true creates a complete encoder per listener, which can use a lot of CPU with many listeners. Use copy-based encoders with dedicated_encoder.

Reverse Proxy and X-Forwarded-For​

When icecast.server runs behind a reverse proxy (e.g. nginx), every listener connects from the proxy's IP. Set x_forwarded_for_proxy_ips to record the listener's own IP.

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. A client can send a forged X-Forwarded-For header, but each proxy appends the IP it received the connection from as the rightmost entry, and that entry is the one 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 custom header names, CIDR matching or CDN-specific headers like CF-Connecting-IP, pass your own callback with 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 a player for each mount
  • /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 replaces the HTML page with a short not-found message using 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, the Matroska and WebM muxers write an index by seeking back into the file when it ends. A live stream cannot seek, so these muxers must be told that they write a live stream.

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=1Clusters follow the WebM DASH specification, output is written as live
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, the callback alone decides: it returns the complete list of options for every format.

Key Differences from Icecast​

Icecast relays the encoded data it receives from a source client to its listeners. Liquidsoap demuxes the incoming stream, passes its encoded packets through a regular liquidsoap source and remuxes them for the mount's output. The content is never decoded or re-encoded, so CPU and memory usage stay low.

This gives icecast.server the following behavior:

  • Fallback happens inside the mount's output. When a source disconnects, the mount switches to the source of its <fallback-mount> and listeners stay connected to the same mount. When the source reconnects, the mount switches back to it.
  • With dedicated_encoder=true and copy encoders, each listener gets a stream that starts with its own container header. Listeners can then join a stream in any streamable container format at any time.
  • Listeners receive the stream identity the source sent, as ice-* or icy-* headers, as icy-name, icy-description, icy-genre, icy-url, icy-br and icy-pub headers.
  • Sources cannot use the mount points of the status pages (/, /status.json) or anything under /admin.

Because of this architecture, the following icecast options do not apply:

  • fallback-override: in icecast, a source that reconnects takes its listeners back from the fallback mount. In liquidsoap, listeners always stay on their mount, which switches back to its source as soon as the source reconnects.
  • fallback-when-full: in icecast, listeners are redirected to a fallback mount once max-listeners is reached. icecast.server refuses them with 403 Forbidden instead.

Using an Icecast Configuration File​

You can use a standard icecast XML configuration file:

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

Liquidsoap reads the supported settings from the file and logs a message for each unsupported option it recognizes. The configuration reference below lists which options are 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>

Reloading the Configuration​

A running server usually has sources and listeners connected, and restarting it to change a password or a fallback would drop all of them. When the server is started with a config file, sending it SIGHUP reads the file again and applies it in place: connected sources and listeners stay connected. With systemd, this is what systemctl reload does given:

ExecReload=/bin/kill -HUP $MAINPID

The same reload is available from the script through the reload() method, for instance to follow changes to the file:

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

def reload() =
try
server.reload()
catch err do
log.important(
"Config not reloaded: #{err.message}"
)
end
end

ignore(file.watch(config, reload))

Parameters passed explicitly to icecast.server keep taking precedence over the file. If the file is invalid, reload() raises an error, or logs it on SIGHUP, and the previous configuration stays in use.

Settings apply at different times:

  • Right away: source authentication (passwords, usernames, <sources>), the admin page credentials, the log files and their <logging> settings, and the <fallback-mount> of live mounts. A mount whose source is gone and whose fallback is removed is shut down, since it has nothing left to send.
  • On the next connection: <on-connect> and <on-disconnect> commands.
  • When a mount is next created: HTTP headers, burst size and client timeout. A running mount keeps the settings it started with.
  • After a restart only: port, bind address, TLS on or off and source timeout. A reload that changes one of them logs that it was not applied.

The TLS certificate and key are read again on every reload, and also once a day on their own, see Renewing certificates.

Configuration Reference​

This section lists the icecast XML configuration options and their status in icecast.server. Options marked as not implemented may be added later. Incompatible options do not fit liquidsoap's architecture, see Key Differences from Icecast.

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
clientsSupportedMaximum number of listeners across all mounts
source-timeoutSupportedTimeout in seconds for source connections (float)
client-timeoutSupportedTimeout in seconds for client connections (float)
burst-sizeSupportedBurst size for new listeners (bytes), 65534 when unset
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. Read again on reload and once a day.
tls-keySupportedPath to separate TLS private key file (icecast 2.5 only)
webrootNot implementedStatic files are not served, see the status page
adminrootNot implementedIcecast's admin interface is not available, see the admin listener page
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
errorlogSupportedError log file, error.log by default. - writes to standard error. See above
playlistlogSupportedPlaylist log file, disabled by default. See above
loglevelSupportedMost verbose error log level, 1/error to 4/debug, 3/info by default
logsizeSupportedSize in KiB past which log files 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-fileNot implemented: ignored with a warning
burst-sizeInitial burst size for new listeners (bytes)
max-listenersMaximum number of listeners on this mount; more are refused with 403 Forbidden
fallback-mountMount to fall back to when this source disconnects. A reload applies it to live mounts too
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
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 do not apply to liquidsoap's architecture, see Key Differences from Icecast.

OptionReason
fallback-overrideListeners stay on their mount, which switches back when its source returns
fallback-when-fullListeners past max-listeners are refused, not redirected

Configuration Sections Not Supported​

The following icecast configuration sections are ignored:

  • fileserve: static file serving. Use harbor HTTP handlers.
  • relay: stream relaying. Use input.http.
  • directory: directory listings (YP).
  • security: chroot and user change. Use your service manager to run liquidsoap as the user you want.

Complete Example​

Here is an example that starts the server from a configuration file and logs DJ connections:

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

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