apps.header.allAppsCount
AirConnect
apps.detail.types.app from i.Flip721's Repository
apps.detail.sections.overview
Readme
View on GitHub
AirConnect
AirPlay to UPnP/Sonos and Chromecast, in a single container, with a web interface for seeing what is actually happening.
Built on philippe44/AirConnect 1.10.1 (MIT), with 12 patches for the reliability and Sonos problems documented below, and a Rust supervisor that makes the bridges observable.
Release Info | Docker Image
docker run -d --name airconnect --network host \
-v ./config:/config \
-e AIRCONNECT_CODEC=mp3:320 \
-e AIRCONNECT_HTTP_LENGTH=-1 \
-e AIRCONNECT_STREAM_TYPE=radio \
iflip721/airconnect:latest
Then open http://your-host:8080.
Why this exists
Three problems, all confirmed by reading the upstream source rather than guessing from symptoms.
Sessions took 10–15 seconds to start
UPnP renderers wait for a Content-Length that a live AirPlay stream can never
provide. Telling Sonos the stream is endless (x-rincon-mp3radio://) rather than
lying about its size takes startup to 564 ms, measured on S2 firmware 17.2.6.
The widely-recommended alternative — advertise a fixed fake length with -g 0 —
starts just as fast (689 ms) but kills the session 30–50 seconds in on S2, so
this distribution does not use it.
Note also that upstream's README recommends -l 1000:2000 for Sonos, and the
most popular AirConnect container bakes it in. That value makes the delay
worse — it adds two seconds of silence buffering on top of the renderer's own,
as a permanent latency floor.
Sessions sometimes never started, or took minutes
Three separate bugs, each of which permanently disables a device while the process still looks healthy — which is why restarting "fixes" it, and why no other AirConnect image's health check notices:
- a remote-control
connect()with no timeout, made while holding the device mutex, stalling for the kernel's full SYN-retry budget (over two minutes on Linux) when the controlling phone slept; - a sender that vanished without
TEARDOWNholding the RTSP session forever, so the speaker appeared in the picker but could never be played to again; - an encoder
malloc'd withcodec,bufferanddatauninitialised, so teardown freed uninitialised heap — on exactly the path a failing session takes.
Older Sonos speakers were unreliable
They were advertised a stream 1 millisecond long (any non-zero duration puts
the DIDL on the musicTrack branch instead of audioBroadcast), volume reads
always failed because of an inverted if in avt_util.c, and speakers randomly
failed to appear because master election matched coordinators from unrelated zone
groups. Being order-dependent, that last one changed on every restart.
Also, the transferMode.dlna.org header that 1.10.0's changelog advertises was
written into the request structure instead of the response, so it never reached
any device.
And nothing was observable
Upstream has no management interface: no status endpoint, no metrics, no state
signals. The only introspection is an interactive stdin console — and the
documented way to run non-interactively (-Z) makes the process call pause()
and never read stdin, disabling even that. Without -Z it uses scanf, which
returns immediately forever on EOF and pegs a CPU core.
So upstream offers a choice between a CPU spin and no control channel, and every
existing AirConnect container takes -Z. This one patches the loop to idle in
pause() on EOF, runs without -Z, and holds the pipe open. Everything the
web UI does is built on that.
Installing
docker compose (recommended)
docker-compose.yml in this repo ships the measured baseline with the reasoning
inline. Copy it and run:
docker compose up -d
It sets AIRCONNECT_WEB_PORT: 8282, so the UI is on http://your-host:8282.
Change that one line if you want the 8080 default.
docker run
The command at the top of this file. Without those three -e flags you get the
sonos-fast preset, which starts fast and then dies on Sonos S2.
Available images
iflip721/airconnect, published 2026-08-03:
| Tag | Contents |
|---|---|
latest, 1.0.0 |
Current release |
0.2.0 |
Previous release |
0.1.0 |
Older |
Each tag is a manifest list covering linux/amd64, linux/arm64 and linux/arm/v7 — about 36 MB compressed, 165 MB on disk. A Raspberry Pi 4/5 (arm64) is a first-class target and is what the measurements in this document were taken on.
Upgrading
docker compose pull && docker compose up -d
Nothing ever self-updates. AIRCONNECT_UPDATE_CHECK only queries the upstream
GitHub release tag and reports it in the UI; no code is downloaded or replaced
inside a running container.
Docker details
Image layout
| Base | debian:bookworm-slim |
| Entrypoint | /usr/local/bin/airconnect-manager, running as PID 1 |
| Binaries | airupnp, aircast (patched), airconnect-manager |
| Volume | /config |
| Exposed | 8080/tcp (documentation only — host networking bypasses it) |
| Healthcheck | curl -fsS http://127.0.0.1:$AIRCONNECT_WEB_PORT/healthz, 30s interval, 20s start period, 3 retries |
| Runtime packages | libssl3, ca-certificates, curl, tzdata |
The base must be glibc. The vendored dependency archives are crosstool-NG glibc objects and will not link against musl, so Alpine is not an option.
libssl3 is a hard requirement rather than an optimisation: the bridges
dlopen() libssl.so.3 at runtime through crosstools' forwarding symbols and
exit if it is missing.
The manager runs as PID 1 deliberately. It owns the child bridges, holds their
stdin control pipes open, reaps them, and translates SIGTERM into an orderly
shutdown. That shutdown goes through the console exit command before signals,
because the bridges' signal handler is not async-signal-safe — it joins threads,
frees, and does I/O from signal context. The manager allows 8s for that;
docker-compose.yml sets stop_grace_period: 15s to stay outside it.
If the manager dies, audio is unaffected: it supervises and observes, and never touches the audio path.
The healthcheck actually checks something
/healthz returns 503 when any configured bridge is not running. Combined with
the restart supervision (exponential backoff to 60s, reset after a bridge has
survived 60s), that catches the crash-loop and wedged cases that no other
AirConnect image detects at all.
If you override the healthcheck in your own compose file, keep the port
expansion. A compose-level healthcheck replaces the image's outright, so a
hardcoded 8080 makes the container permanently unhealthy the moment
AIRCONNECT_WEB_PORT changes.
Volumes
/config holds airupnp.xml and aircast.xml. Mount it to keep discovered
devices across upgrades. The directory must be writable — a read-only mount also
crashes upstream's config writer, which does not check whether the file opened.
The Diagnostics page probes for this.
Networking
--network host is mandatory.
AirPlay discovery is mDNS multicast (5353/udp), UPnP discovery is SSDP multicast (1900/udp), and the bridges bind ephemeral RTSP/RTP/HTTP ports per device unless you pin a range. None of that survives a bridge network. The Diagnostics page detects bridge mode and says so rather than leaving you with an empty device list.
Two other environmental failures, both checked automatically:
- avahi on the host may hold 5353 exclusively. Set
disallow-other-stacks=noin/etc/avahi/avahi-daemon.conf. The bridges bind withSO_REUSEADDR/SO_REUSEPORT, so this is not always fatal. - IGMP snooping on a switch or router will silently filter discovery.
On a multi-homed host set AIRCONNECT_BIND_IP, or discovery may bind the wrong
interface.
Configuration
Everything is environment variables. Upstream supports none — there is not a
single getenv in its tree — so the manager translates them into command-line
flags.
The shipped baseline
docker-compose.yml does not use a preset unmodified. It takes sonos-fast for
its latency and overrides the rest — the only one of four configurations measured
on Sonos S2 that both starts quickly and stays up:
AIRCONNECT_CODEC: "mp3:320" # FLAC (~900 kbit/s) outruns a weak wireless link
AIRCONNECT_HTTP_LENGTH: "-1" # NOT 0 — that means INT_MAX, and stalls S2
AIRCONNECT_STREAM_TYPE: radio # requires a lossy codec, or it plays nothing
The evidence, four sessions against one S2 coordinator on firmware 17.2.6, each played until it failed or was clearly stable:
| Codec | -g |
-S |
Time to audio | Result |
|---|---|---|---|---|
| flac | 0 (INT_MAX) |
broadcast | 689 ms | dies at 30.7 s |
| flac | -3 (chunked) |
broadcast | 1826 ms | dies at 47.5 s |
| flac | -1 (none) |
radio | — | never plays at all |
| mp3:320 | -1 (none) |
radio | 564 ms | stable |
Two conclusions, only one of them expected. Framing is not sufficient — removing the length entirely did not save the FLAC stream, it only bought 17 more seconds. The dominant variable is bitrate: delivery settled at 67% of real time, so the sustainable ceiling sits near 600 kbit/s, and FLAC out of this encoder runs about 900.
That ceiling is a property of the link, not the firmware. The failing coordinator was wireless with 2.06% packet loss; a second speaker on the same firmware and network reported zero drops. Check a speaker's link before blaming the bridge:
curl -s http://<sonos-ip>:1400/status/ifconfig | grep -A3 -E 'br0|eth0'
eth0 at 0 packets means the speaker is wireless, and br0's dropped counter
gives the quality directly.
Presets
AIRCONNECT_PRESET |
Flags it implies | When |
|---|---|---|
sonos-fast (default) |
-g 0 -l 50:100 |
Firmware where you have verified a long session survives — not S2 |
compat |
-c wav -g 0 -l 50:100 |
Firmware that refuses FLAC. WAV's ~4 GB header caps a session at 3–6 hours |
upstream |
none | Reproducing stock upstream behaviour |
Aliases: fast/sonos_fast, wav, stock/default. Any unrecognised value is
treated as custom and implies nothing, leaving every setting to the variables
below.
The built-in default stays sonos-fast so a bare docker run reproduces the
historical behaviour — which means a bare docker run will show a Diagnostics
warning about -g 0. Use the compose file, or set the three variables above.
Variables
Explicit variables always override whatever the preset implied.
Core
| Variable | Default | Notes |
|---|---|---|
AIRCONNECT_MODE |
both |
both, upnp, cast |
AIRCONNECT_PRESET |
sonos-fast |
See above |
AIRCONNECT_CONFIG_DIR |
/config |
Mount a volume here to keep settings |
AIRCONNECT_WEB_PORT |
8080 |
Taken on the host directly, under host networking |
AIRCONNECT_WEB_BIND |
0.0.0.0 |
|
AIRCONNECT_UPDATE_CHECK |
true |
Notify only — nothing ever self-updates |
TZ |
UTC |
Not read by AirConnect — glibc uses it to render local timestamps |
Audio and timing
| Variable | Default | Notes |
|---|---|---|
AIRCONNECT_CODEC |
preset | flac[:0-9][/1152-16384], wav, pcm, mp3[:rate], aac[:rate]. Must be lossy if STREAM_TYPE=radio |
AIRCONNECT_HTTP_LENGTH |
preset | -3 chunked, -1 none, 0 fixed = INT_MAX, not "no length". airupnp only |
AIRCONNECT_STREAM_TYPE |
binary default (broadcast) |
Sonos only: broadcast, track, radio. airupnp only |
AIRCONNECT_LATENCY |
preset | rtp:http[:f] in ms |
AIRCONNECT_FLUSH |
true |
false passes --noflush |
AIRCONNECT_DRIFT |
false |
true passes -r |
AIRCONNECT_STREAM_TYPE selects how a Sonos is told to present the stream, and
only affects Sonos — every other renderer always gets audioBroadcast:
| Value | DIDL | URI |
|---|---|---|
broadcast (default) |
audioBroadcast, no res@duration |
http:// |
track |
musicTrack, res@duration="0:00:00.001" |
http:// |
radio |
audioBroadcast, no res@duration |
x-rincon-mp3radio:// |
radiorequires a lossy codec. This build lifts upstream's mp3/aac gate on the scheme, and the patch comment claims it is a generic endless-stream scheme. On S2 firmware that is false: a FLAC payload over it is fetched continuously and never played — the speaker re-requests the stream every ~19 seconds indefinitely while/api/sessionsstill reports a healthy session serving audio. Upstream's gate was load-bearing. This is the only completely silent failure in the product, so the Diagnostics page fails the combination explicitly.
Network
| Variable | Default | Notes |
|---|---|---|
AIRCONNECT_BIND_IP |
auto | Set on multi-homed hosts. AIRCONNECT_BIND_INTERFACE is accepted as an alias |
AIRCONNECT_UPNP_PORT |
auto | Must be ≥ 49152. Appended to the airupnp binding |
AIRCONNECT_PORT_BASE |
ephemeral | Pins the RTSP/RTP/HTTP pool |
AIRCONNECT_PORT_RANGE |
Size ≥ (devices × 4) + 1 |
Device selection and naming
| Variable | Applies to | Flag |
|---|---|---|
AIRCONNECT_NAME_FORMAT |
both | -N |
AIRCONNECT_EXCLUDE_MODELS |
airupnp | -m |
AIRCONNECT_EXCLUDE_NAMES |
airupnp | -n |
AIRCONNECT_INCLUDE_MODELS |
airupnp | -o |
AIRCONNECT_MEDIA_VOLUME |
aircast | -v |
Logging and escape hatches
| Variable | Notes |
|---|---|
AIRCONNECT_LOG_{MAIN,UTIL,RAOP,UPNP,CAST} |
error…sdebug. Starting values only — the UI changes them live. Components are filtered per bridge, so CAST is never passed to airupnp |
AIRCONNECT_LOG |
The manager's own tracing filter (info by default), unrelated to the bridges |
AIRCONNECT_{UPNP,CAST}_EXTRA_ARGS |
Raw flags appended verbatim. Simple double quoting is honoured, so -N "Living Room %s" survives |
-Z is never passed, and passing it yourself via the extra-args escape hatch
would disable the bridges' console and with it every live feature of the web
interface — session detail, log streaming and runtime log levels.
Per-device configuration
The bridges resolve precedence as per-device XML > command line > <common>
XML, so per-device overrides belong in config.xml rather than in flags.
Not yet implemented. The bridges are started with -x /config/<bridge>.xml
but nothing ever writes that file — upstream only saves it with -i/-I,
neither of which is passed — so the file never exists and every setting above is
currently global. This also puts real levers out of reach: <metadata> gates the
ICY injection that splits a send at every 16 KB boundary, and there is no
command-line flag for it.
The web interface
Five views, served from the manager binary with no external assets.
Sessions is the reason this exists. Every AirPlay handoff is drawn as the chain it actually is:
connected ──▶ streaming ──▶ told what ──▶ told play ──▶ audio out
RTSP RECORD SetAVTransportURI Play renderer GET
with the elapsed milliseconds at each step. time_to_audio_ms is measured from
RECORD, not from the RTSP connection, because that is the interval users
experience as "the delay". When a session stalls on the way up, the phase it
stopped at identifies the cause, and the page names the remedy instead of showing
a gap.
Its limit is worth stating plainly: every rule is about starting, none about
staying up. A session that reaches "audio out" and then degrades or dies is
reported as healthy, and so is one that serves bytes the speaker never plays.
Diagnosing those still means reading the log's drain … gap: trend by hand.
Devices — everything discovered, its transport state, error count, and whether it is wedged behind a blocked call (the bridge reports whether the device mutex was held when sampled; persistently set is the signature).
Logs — live over WebSocket, filterable by severity, subsystem and substring, and verbosity is changeable on a running bridge. Turning on RAOP debug to diagnose a session no longer means killing the session you are diagnosing. The buffer holds the last 5,000 records; session history holds the last 100.
Diagnostics — five checks on the environment the bridges depend on but cannot report themselves: host vs bridge networking, SSDP multicast group membership, port 5353 conflicts, config writability, and whether the startup tuning is coherent. One click exports a bundle (config, logs, sessions, checks, redacted environment) suitable for handing to a maintainer.
Overview — bridge state, counts, and any startup notices.
API
The UI is one client of a documented JSON API — anything it does is scriptable.
| Endpoint | Purpose |
|---|---|
GET /healthz |
Liveness; 503 when a configured bridge is down |
GET /api/status |
Bridge state, argv, counts, notices |
GET /api/devices |
Discovered renderers, with a decoded state_name |
GET /api/sessions |
Session timelines and diagnoses, newest first |
GET /api/logs |
Filter by level, component, q, since, limit (default 500, max 5000) |
GET /api/diagnostics |
Environment checks |
GET /api/config |
Resolved config, including the exact argv each bridge was given |
GET /api/update |
Upstream release tag; notify-only |
GET /api/bundle |
Everything above in one downloadable file |
POST /api/bridges/:bridge/loglevel |
{"component":"raop","level":"debug"} |
POST /api/bridges/:bridge/rescan |
Re-poll device status now |
WS /api/events |
Live logs and change notifications |
:bridge is airupnp/upnp or aircast/cast. Anything matching PASS,
TOKEN, SECRET or KEY is redacted from /api/config and /api/bundle,
which are meant to be shared with strangers.
curl -s localhost:8080/api/sessions | jq '.sessions[0].phases'
curl -s -XPOST localhost:8080/api/bridges/airupnp/loglevel \
-H 'content-type: application/json' -d '{"component":"raop","level":"debug"}'
Building
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
-f docker/Dockerfile -t iflip721/airconnect:latest .
Upstream is fetched at pinned SHAs and patched at build time — nothing is
vendored into the working tree, because a recursive clone is ~2.7 GB including a
checked-in 93 MB release zip. Fetching only the submodules actually compiled
against brings that to ~175 MB. If those repositories are unreachable, the build
falls back to a checksummed snapshot published on this repo's releases; see
Pins. Every stage cross-compiles from $BUILDPLATFORM rather than
emulating, so a three-arch build does not take three times as long, and Rust
never runs under qemu.
Five stages: platform (maps TARGETPLATFORM onto Debian's multiarch name, the
GNU triple, upstream's targets/ name and the Rust triple), web (stages the UI
into dist/), cbuild (fetch, patch, cross-compile the bridges), rustbuild
(cross-compile the manager with the UI embedded), and the runtime image.
Working on the bridges
scripts/fetch-upstream.sh fetch pinned sources into build/upstream
scripts/apply-patches.sh apply patches/series, failing loudly on drift
scripts/build-bridges.sh rebuild patched objects, build both bridges
scripts/refresh-patches.sh regenerate patches/ after re-rolling
scripts/make-upstream-snapshot.sh build the source snapshot for a new pin
patches/series is grouped by target repository, because AirConnect's submodules
are separate git repos — a patch to common/libraop must be applied inside it,
not at the top level. Patches apply with git apply --3way, so an upstream bump
that breaks one produces a conflict report rather than a bare "does not apply".
Two build details worth knowing before changing anything:
- libraop and libcodecs are shipped prebuilt. Rather than rebuild those
archives,
build-bridges.shrecompiles only the three translation units we patch (raop_server.c,raop_streamer.c,encoder.c) and splices them in withar r. Rebuilding libraop wholesale would drag in the curve25519 submodule that we deliberately do not fetch and the bridges never reference. - There is a licence gate.
libcodecs.atransitively references libmad and libfaad, both GPL-2.0. The bridges only ever encode, and decode ALAC via libraop's ownalac.c, so no member of either should be linked — but the build asserts it withnmand fails rather than publishing an image that would have to be offered under the GPL.
Manager only
The manager is a normal Cargo project in manager/, with unit tests covering
argv construction, the tuning checks, session-timeline folding and the log
parser:
cd manager && cargo test
AIRCONNECT_WEB_DIST points rust-embed at the built UI; the Dockerfile sets it
to the staged web-dist. The web build is deliberately dependency-free — it
copies web/src to web/dist and nothing else — so there is no bundler supply
chain in the image.
Releasing
Publishing is the Publish Docker image workflow, run manually from the
Actions tab. It cross-compiles, pushes to Docker Hub with the DOCKER_HUB_KEY
repository secret, tags the commit and writes the release notes.
It takes one tag field, split on the first -:
| Input | Tags pushed |
|---|---|
1.0.0-latest |
1.0.0 and latest |
1.0.0-dev |
1.0.0 and dev |
1.0.0 |
1.0.0 only |
dev |
dev only |
The version is whatever you type. It is not checked against manager/Cargo.toml
and does not have to look like a version at all — the only constraint is that
each half be a tag Docker will accept — so a new version can be cut whenever you
want one, with or without bumping the tree first.
A -dev run republishes the version tag too, since that is what the delimiter
rule says; dispatch a bare dev to push a test image without touching a version
tag. Every run tags the commit v<version> and publishes a GitHub Release whose
environment-variable section is extracted from the env-vars markers in this
file; untick push_git_tag or create_release for a build that should leave no
trace beyond the image. A second tag of exactly dev marks the release as a
prerelease.
The platforms input defaults to linux/amd64,linux/arm64. Pass
linux/amd64,linux/arm64,linux/arm/v7 to keep the manifest three-wide, as the
Available images table above describes — that table is hand-maintained and
does not update itself.
.github/scripts/check-env-docs.sh --strict diffs the variables the manager
reads against the ones documented here; the workflow runs it non-blocking.
Publish upstream snapshot is the other manual workflow, and it runs on a
different clock — once per upstream.lock change, not per image release. See
Pins.
Pins
upstream.lock pins upstream at tag 1.10.1 (42b933b) plus eight submodule
SHAs. common/libopenssl (677 MB) is skipped because only the -static link
variant needs it, and common/libpthreads4w is Windows-only. Bumping a pin is a
deliberate, reviewable change.
Git SHAs are content-addressed, so the pins already guarantee what gets built: a force-push, a retagged release or a compromised account cannot hand the build different bytes than the patches were written against. What they cannot guarantee is that those repositories still exist.
So the same tree is also published as a release asset here, and
fetch-upstream.sh falls back to it when the clone fails:
AIRCONNECT_SOURCE=git # clone only; an upstream outage fails the build
AIRCONNECT_SOURCE=snapshot # snapshot only; how the workflow verifies itself
AIRCONNECT_SOURCE=auto # default — clone, then snapshot if that fails
The fallback is not a weaker path. AIRCONNECT_SNAPSHOT_SHA256 in
upstream.lock is checked before anything is unpacked, and an empty hash
disables the fallback rather than trusting an unverified download — an
availability backstop never becomes a way in for bytes the pins did not
authorise.
The snapshot is a verbatim copy of the pinned tree, ~51 MB compressed, minus
only what the build already discards: upstream's 93 MB prebuilt release zip and
the 80 MB of cliraop executables under common/libraop/bin. Both are pruned on
the clone path too, so the two sources produce identical trees.
Storing just the files patches/ touches would not have worked. The patches
cover ~15 files, but build-bridges.sh recompiles three translation units and
splices them with ar r into prebuilt archives it never builds — the tree is the
unit that has to survive, not the patched files.
Rebuild it after any pin change with the Publish upstream snapshot workflow,
which tags a release after the pin rather than an image version
(upstream-1.10.1-42b933b59523), verifies the published asset by fetching and
patching through it, and only then commits the hash back to upstream.lock.
scripts/make-upstream-snapshot.sh does the same thing locally and prints the
two lines to record.
Licence
MIT, as upstream. Third-party components are listed in THIRD-PARTY-NOTICES.md; the build asserts that no GPL-only decoder objects are linked into the published binaries.
Upstream AirConnect is the work of philippe44, and this is a packaging and patching layer on top of it. Bugs found here are reported upstream where they apply.
apps.marketingCta.appInstallTitle
apps.marketingCta.appInstallDescription
apps.detail.sections.categories
apps.downloadStats.title
apps.detail.sections.related
apps.detail.related.exploreCategories
apps.detail.related.exploreAllapps.detail.sections.links
apps.detail.sections.details
iflip721/airconnect:latestapps.detail.sections.runtime
- apps.detail.details.webui
http://[IP]:8282- apps.detail.details.network
host- apps.detail.details.shell
sh- apps.detail.details.privileged
- false
- apps.detail.details.extraParams
--stop-timeout=15
apps.detail.sections.configuration
Holds airupnp.xml and aircast.xml, so discovered devices survive an upgrade. The directory must be writable — a read-only mount crashes upstream's config writer.
- apps.detail.config.target
- /config
- apps.detail.config.value
- /mnt/cache/appdata/airconnect/config
Which bridges to run. Options: both (default) | upnp (alias airupnp) | cast (alias aircast). Any unrecognised value falls back to both.
- apps.detail.config.value
- both
Tuning preset. Options: sonos-fast (default, implies -g 0 -l 50:100; aliases fast, sonos_fast) | compat (adds -c wav for firmware that refuses FLAC, but WAV caps a session at roughly 3-6 hours; alias wav) | upstream (stock upstream behaviour, no overrides; aliases stock, default). Any other value is treated as custom and implies nothing. Explicit variables below always override whatever the preset implied.
- apps.detail.config.value
- sonos-fast
Port for the web UI, JSON API and /healthz. Under host networking this port is taken directly on the host, so change it here if 8282 is already in use. The image default is 8080. If you change it, update the WebUI link on this template to match.
- apps.detail.config.value
- 8282
Stream codec (-c). Options: flac[:0-9][/1152-16384] | wav | pcm | mp3[:rate] | aac[:rate]. Must be lossy (mp3 or aac) when AIRCONNECT_STREAM_TYPE is radio — a FLAC payload over x-rincon-mp3radio:// is fetched continuously and never played. FLAC also runs about 900 kbit/s, which a Sonos on a marginal wireless link will not sustain. Leave blank to use the preset.
- apps.detail.config.value
- mp3:320
Content length advertised to the renderer (-g), airupnp only. Options: -3 (chunked) | -1 (advertise no length) | 0 (fixed — this means INT_MAX, NOT 'no length', so the renderer paces its reads as if fetching a 2 GiB track and stalls Sonos S2 30-50s in) | a byte count. Leave blank to use the preset.
- apps.detail.config.value
- -1
How a Sonos is told to present the stream (-S), airupnp only; every other renderer always gets audioBroadcast. Options: broadcast (audioBroadcast, no res@duration — the binary default) | track (upstream's musicTrack with a 1ms res@duration; combined with -g 0 this makes S2 firmware pace the stream like a file and drop it) | radio (audioBroadcast plus Sonos' x-rincon-mp3radio:// scheme, which removes the 10-15s startup wait; requires a lossy codec). Leave blank to pass no -S at all.
- apps.detail.config.value
- radio
Check GitHub for a newer upstream release tag. Notify only — nothing ever self-updates. Options: true (default) | false. Also accepts 0, no, off and disabled as false; any other value is true.
- apps.detail.config.value
- true
Not read by AirConnect itself — glibc uses it to render local timestamps in logs and the web UI. Any tz database name, e.g. America/New_York, Europe/London, Etc/UTC.
- apps.detail.config.value
- America/New_York
Bind address for the web UI and API. Options: 0.0.0.0 (all interfaces, default) | 127.0.0.1 (localhost only) | a specific host IP.
- apps.detail.config.value
- 0.0.0.0
Latency (-l), given as rtp:http[:f] in milliseconds, e.g. 50:100. Leave blank to use the preset.
Options: true (default) | false. Setting false passes --noflush to the bridges. Also accepts 0, no, off and disabled as false.
- apps.detail.config.value
- true
Clock drift correction. Options: false (default) | true. Setting true passes -r to the bridges.
- apps.detail.config.value
- false
Interface used for discovery (-b). Accepts an IP address or an interface name. Set this on a multi-homed host, where discovery would otherwise bind the wrong interface. Leave blank to auto-detect.
Alias for AIRCONNECT_BIND_IP. Only consulted when AIRCONNECT_BIND_IP is unset.
UPnP control port, appended to the airupnp binding as -b ip:port. airupnp only. Must be 49152 or above. Leave blank to auto-assign.
Pins the per-device RTSP/RTP/HTTP port pool (-a) instead of using ephemeral ports, e.g. 49200. Leave blank for ephemeral ports.
Size of the pinned port pool; only used when AIRCONNECT_PORT_BASE is set. Size it for at least (devices x 4) + 1, e.g. 128.
Device name format (-N), applied to both bridges. %s is the discovered device name, so "%s+" appends a plus to each one.
Comma-separated list of model names to skip (-m). airupnp only.
Comma-separated list of device names to skip (-n). airupnp only.
Comma-separated allow-list of model names (-o). airupnp only.
Media volume (-v). aircast only.
Starting log level for the bridges' main component (-d main=level). Options: error | warn | info | debug | sdebug. Starting value only — the web UI changes levels on a running bridge without a restart, which is how you capture a session without dropping it.
Starting log level for the util component (-d util=level). Options: error | warn | info | debug | sdebug. Changeable live from the web UI.
Starting log level for the raop (AirPlay) component (-d raop=level). Options: error | warn | info | debug | sdebug. Changeable live from the web UI.
Starting log level for the upnp component (-d upnp=level). Options: error | warn | info | debug | sdebug. Never passed to aircast. Changeable live from the web UI.
Starting log level for the cast component (-d cast=level). Options: error | warn | info | debug | sdebug. Never passed to airupnp. Changeable live from the web UI.
The manager's own tracing filter, unrelated to the bridge log levels above. Options: error | warn | info (default) | debug | trace, or any tracing EnvFilter directive such as airconnect_manager=debug.
- apps.detail.config.value
- info
Raw flags appended verbatim to airupnp. Simple double quoting is honoured, so -N "Living Room %s" survives. Do not pass -Z: it disables the bridge console and with it every live web UI feature (session detail, log streaming, runtime log levels).
Raw flags appended verbatim to aircast. Simple double quoting is honoured, so -N "Living Room %s" survives. Do not pass -Z: it disables the bridge console and with it every live web UI feature (session detail, log streaming, runtime log levels).