Multi-room audio system with smart home integration
One binary. AirPlay + Spotify + Subsonic + MQTT + KNX. Snapcast-based audio delivery.
SnapDog turns a Linux box (or a Mac) into a synchronized multi-room audio system with deep smart home integration. It embeds a Snapcast compatible server completely reimplemented in pure Rust (see snapcast-rs), runs AirPlay and Spotify Connect receivers per zone, streams from subsonic-compatible media servers like Navidrome, plays internet radio — and bridges everything tightly to MQTT and KNX.
| 🔊 Snapcast | Synchronized playback, embedded server snapcast-rs or external snapcast process |
| 🎵 AirPlay 1 + 2 | Per-zone receivers, stream from iPhone/Mac |
| 🎧 Spotify Connect | Per-zone receivers via librespot |
| 📻 Internet Radio | Station list with live ICY metadata (artist/title parsing, dynamic cover art) |
| 📚 Subsonic/Navidrome | Personal music library with playlist navigation, seek, search, and playlist editing through the API |
| 💾 Track Cache | Disk-backed LRU cache for Subsonic tracks — instant seek, replay, and look-ahead prefetch |
| ⚡ Source Conflict | Configurable priority: last_wins or receiver_wins (AirPlay/Spotify vs local) |
| 🎨 Cover Art | Content-addressed caching, ICY StreamUrl fallback, unified per-zone endpoint |
| 🎛️ Multiband Parametric EQ | Per-zone and per-client, genre presets, real-time via custom protocol |
| 🔊 Speaker Correction | Per-client Spinorama profiles (1000+ speakers from (https://spinorama.org)) |
| 🔀 Audio Fade | Smooth transitions: zone switch (client-side) and source switch (server-side) |
| 🏠 MQTT | Bidirectional smart home integration over MQTT 3.1.1: retained state, commands, availability; TLS and mutual TLS |
| 🏢 KNX | Building automation — client mode (tunnel/router) or device mode (ETS-programmable, 44 group objects per zone, 11 per client, plus 5 device-level global objects, in eight languages) |
| 🌐 REST API | ~100 endpoints, full zone/client/media control |
| 📡 WebSocket | Real-time state push notifications |
| 🖥️ WebUI | Responsive SPA, drag-and-drop, tabbed EQ overlay, i18n (8 languages: English, German, French, Spanish, Italian, Dutch, Polish, Portuguese) |
docker run -d --name snapdog \
--restart unless-stopped \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--cap-drop ALL \
--security-opt no-new-privileges \
--stop-timeout 15 \
-v snapdog-data:/var/lib/snapdog \
-v ./snapdog.toml:/etc/snapdog/snapdog.toml:ro \
-p 5555:5555 -p 1704:1704 -p 3671:3671/udp \
ghcr.io/snapdogrocks/snapdog:latestDocker Compose (Production)
services:
snapdog:
image: ghcr.io/snapdogrocks/snapdog:latest
restart: unless-stopped
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
stop_grace_period: 15s
volumes:
- snapdog-data:/var/lib/snapdog
- ./snapdog.toml:/etc/snapdog/snapdog.toml:ro
ports:
- "5555:5555" # WebUI + REST API
- "1704:1704" # Snapcast streaming
- "3671:3671/udp" # KNX/IP device
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "--show-error", "--max-time", "4", "http://127.0.0.1:5555/health/live"]
interval: 30s
timeout: 5s
retries: 3
snapdog-client:
image: ghcr.io/snapdogrocks/snapdog-client:latest
restart: unless-stopped
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
stop_grace_period: 15s
devices:
- /dev/snd
command: ["tcp://snapdog:1704"]
depends_on:
snapdog:
condition: service_healthy
volumes:
snapdog-data: # Persists KNX programming, state, EQ configPublished images are multi-architecture (linux/amd64, linux/arm64) and include
SBOM and SLSA provenance attestations. Release manifests are signed keylessly with
Sigstore. Verify an immutable release before deployment:
cosign verify ghcr.io/snapdogrocks/snapdog:v0.25.1 \
--certificate-identity-regexp '^https://github\.com/SnapDogRocks/snapdog/\.github/workflows/release\.yml@refs/tags/v[0-9].*$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comFor unattended, signature-verified updates with health-gated rollback, use the
production Compose updater. For reproducible deployments,
pin a version or digest instead of latest. On Linux,
mDNS discovery and KNX/IP routing use multicast and generally require host networking
or a multicast-capable macvlan/ipvlan network; published ports alone cover unicast
traffic. AirPlay 2 also listens on UDP 319 and 320 and registers _airplay._tcp
(see the upgrade notes).
# Start as KNX/IP device — ETS programs zones, clients, group addresses and parameters
snapdog --knx-device --knx-address 1.1.100
# Programming mode can be enabled via CLI flag, About dialog in WebUI, or REST API
snapdog --knx-device --knx-address 1.1.100 --knx-prog-mode
# Dual-stack IPv4+IPv6
snapdog --knx-device --knx-address 1.1.100 --bind ::Started this way (device mode, no --config), SnapDog configures itself entirely
from ETS: the parameters ETS downloads — zones, clients, radios, Subsonic and
MQTT endpoints, HTTP port, log level — are persisted and applied as the running
configuration on start. When ETS finishes a programming session (sending
A_Restart), SnapDog persists the new programming and restarts itself so the
changes take effect immediately (disable with restart_after_ets = false). Until a
device has been programmed, built-in defaults are used.
The .knxprod file for ETS import is available from Releases.
Running servers also expose the exact embedded product database at
GET /api/v1/knx/knxprod and its application/hardware identity at
GET /api/v1/knx/product-info, so integrators can verify that ETS and firmware
use the same product version.
Download from Releases, verify the measured digest, then run it:
sha256sum --check SHA256SUMS --ignore-missing
snapdog --config snapdog.tomlOn Windows, SnapDog can run as a native service:
sc create SnapDog binPath= "\"C:\Program Files\SnapDog\snapdog.exe\" --service --config \"C:\ProgramData\snapdog\snapdog.toml\""
sc start SnapDogPackages come from deb.snapdog.cc. Check the key before you trust it:
curl -fsSL https://deb.snapdog.cc/snapdog-archive-keyring.pgp -o /tmp/snapdog-archive-keyring.pgp
gpg --show-keys --with-colons /tmp/snapdog-archive-keyring.pgp \
| awk -F: '$1 == "fpr" { print $10; exit }'
# Expected: 1B7B79417383648BBFBE282E01AB8296EF0FCD76That is the certifying primary key. It signs nothing itself; the packages for
this domain are signed by its subkey
CFE7BACA652DD0D72EB7156FC7252FAD41F8F7DA. Only install once the primary
fingerprint above matches:
sudo install -m 0644 /tmp/snapdog-archive-keyring.pgp \
/usr/share/keyrings/snapdog-archive-keyring.pgp
echo "deb [signed-by=/usr/share/keyrings/snapdog-archive-keyring.pgp] https://deb.snapdog.cc rolling main" \
| sudo tee /etc/apt/sources.list.d/snapdog.list
sudo apt update
sudo apt install snapdog snapdog-clientDo not add trusted=yes. It disables exactly the check the fingerprint above is
for.
The packages install the systemd units and manage them on install, upgrade and removal.
- Fresh install:
snapdog-clientis enabled and started. It finds its server through mDNS.snapdogis installed disabled, because it needs a configuration first. Edit/etc/snapdog/snapdog.toml, then runsudo systemctl enable --now snapdog. - Upgrade: a service that runs is restarted, which interrupts the audio for a moment. Whether a service is enabled stays as you left it. A stopped
snapdogstays stopped, and a disabled or maskedsnapdog-clientstays that way. Asnapdog-clientthat is enabled but stopped is started again, because the package starts it on every upgrade. - Removal: both services are stopped. If
snapdogwas enabled and running when you removed it, installing it again starts it again, also after a reboot, as long as you did not purge it in between. - Purge: also deletes what
snapdogkeeps in/var/lib/snapdog, such as pairing keys and the ETS and EQ settings. Ifsnapdogstill runs, because apolicy-rc.don the host keeps packages from stopping services, the directory is kept and the purge says so. Stop the service and delete/var/lib/private/snapdogand/var/lib/snapdogyourself then.
Upgrading from 0.28.2 or older. Those packages shipped the unit files but no maintainer scripts, so nothing recorded whether you ever enabled snapdog-client. The upgrade therefore leaves it as it is: if you enabled it, it stays enabled and is restarted, and otherwise it stays disabled and stopped. Only a fresh install enables it. The same holds if you removed the old package without purging it and install the new one. snapdog is not enabled either, and one you enabled yourself is restarted like any running service, so expect a short gap in the audio.
Until September 2026 the packages were served from a branch of this repository
at https://snapdogrocks.github.io/snapdog/debian, signed with the key
E4AAC210C8C21377554DBDE40623E5F3B4379FC7. That location is frozen at 0.27.4
and receives nothing further. It is not redirected, because GitHub Pages cannot
redirect and apt would ignore an HTML one.
sudo rm -f /etc/apt/sources.list.d/snapdog.list \
/usr/share/keyrings/snapdog-archive-keyring.ascThen follow the steps above. The package names do not change, so an installed system upgrades in place.
brew tap snapdogrocks/tap
brew install snapdog
brew install snapdog-clientyay -S snapdog # or: yay -S snapdog-clientThe Debian and AUR packages install a small starter configuration as /etc/snapdog/snapdog.toml: one zone named "Living Room" (the name AirPlay and
Spotify show), Spotify and AirPlay with their defaults, the web UI on port 5555. Earlier packages installed the
full example instead (two zones, three speakers, an MQTT broker, a Subsonic server), which started services that
were not yours. The full reference stays in snapdog.example.toml and at https://snapdog.cc/docs/configuration.
The file is a package configuration file. If you edited it, dpkg asks before replacing it and pacman leaves your
file and writes the new one next to it as snapdog.toml.pacnew. A copy you never edited is replaced by the starter
configuration on upgrade, so the zone names, and with them the AirPlay and Spotify names, the MQTT entities and the
KNX bindings of the old example, change. Copy the sections you want from the example before you upgrade, or restore
them afterwards.
The AUR recipes install the same measured Linux binaries used by the release tarballs, Debian packages and container images; they do not rebuild from source.
Download SnapDog-Server-*.dmg from Releases. The menu bar app embeds the server binary, manages start/stop, and includes Sparkle auto-update.
Requires Rust 1.94 or newer. Use the committed lockfile; see dependency maintenance for upstream compatibility holds.
cargo build --release --locked
./target/release/snapdog --config snapdog.tomlAccess:
| WebUI | http://localhost:5555 |
| API | http://localhost:5555/api/v1/zones |
| Health | http://localhost:5555/health |
| WebSocket | ws://localhost:5555/ws |
Single file: snapdog.example.toml
# Server display name — used for mDNS, MQTT, browser title
# name = "SnapDog"
# [network]
# bind = ["192.168.1.10"] # Addresses every service listens on, unless it sets its own `bind`
[http]
port = 5555
bind = "::" # default; 0.0.0.0 for IPv4 only; a list listens on each address
base_url = "http://192.168.1.10:5555" # Required for MQTT cover art URLs; REST API uses relative URLs
# tls_cert = "/etc/snapdog/tls/fullchain.pem" # Enables HTTPS
# tls_key = "/etc/snapdog/tls/privkey.pem"
[audio]
sample_rate = 48000
bit_depth = 16 # FLAC: max 24; f32lz4: always 32 (setting ignored)
channels = 2
source_conflict = "last_wins" # last_wins | receiver_wins
zone_switch_fade_ms = 300 # Client zone switch fade (0 to disable, max 1000)
source_switch_fade_ms = 300 # Source change fade within a zone (0 to disable, max 1000)
[snapcast]
streaming_port = 1704
# streaming_bind = "192.168.10.5" # One address for the audio port (default: [network].bind if it is one address, else all interfaces)
unknown_clients = "accept" # accept | ignore | reject
default_zone = "Living Room" # Zone for unknown clients (accept only)
[mdns]
# enabled = true # Advertises _snapdog._tcp via OS daemon
# advertise_snapcast = false # Additionally advertise _snapcast._tcp
[dbus]
# enabled = true # MPRIS2 per zone (Linux only)
[airplay]
# mode = "airplay2" # airplay1 | airplay2 (airplay2 needs a build with feature ap2)
# password = "1234"
[spotify]
# name = "Home" # Spotify device name: exactly this with one zone, "<name> <zone>" with several
[mqtt]
broker = "192.168.1.10:1883" # or "mqtts://broker.example.org:8883" for TLS
# client_id = "snapdog" # Must be unique per broker
# username = "user"
# password_file = "/run/secrets/mqtt_password" # or password = "pass", or SNAPDOG_MQTT_PASSWORD
# ca_file = "/etc/snapdog/mqtt-ca.pem" # private CA (TLS only)
# client_cert = "/etc/snapdog/mqtt-client.pem" # mutual TLS (TLS only)
# client_key = "/etc/snapdog/mqtt-client.key"
base_topic = "snapdog/"
[subsonic]
url = "https://music.example.com"
username = "user"
password = "pass"
# format = "raw" # raw | flac | mp3 | opus
# refresh_secs = 60 # Read the playlist list this often, a change sends `playlists_changed` (0 = off)
# playlist_write = false # Let the REST API create, rename, reorder and delete Subsonic playlists
# [subsonic.cache]
# path = "~/.cache/snapdog/tracks" # Cache directory
# max_size_mb = 2048 # LRU eviction
[knx]
# role = "client" # Connect to a KNX/IP gateway
url = "udp://192.168.1.50:3671"
# role = "device" # Run as ETS-programmable KNX/IP device
# individual_address = "1.1.100"
# persist_ets_config = true # Persist ETS programming across restarts (device role)
# restart_after_ets = true # Restart to apply new parameters after an ETS reprogram
[[zone]]
# name = "SnapDog"
[[client]]
name = "Kitchen Speaker"
mac = "02:42:ac:11:00:10"
zone = "Living Room"
[[radio]]
name = "Deutschlandfunk"
url = "https://st01.sslstream.dlf.de/dlf/01/high/aac/stream.aac"Snapcast sink paths, stream names, and AirPlay names are auto-generated from zone/client definitions. KNX addresses are explicit in client mode (fits into existing installations). In device mode, ETS assigns group addresses via the .knxprod product database.
These changes came with the configuration and command-line fixes. Each one is a deliberate choice; the alternative is named.
-
Command-line overrides are validated like file values.
--sample-rateaccepts 44100, 48000, 88200, 96000, 176400 and 192000,--bit-depth16, 24 and 32, and the ports must differ, exactly as the same keys in the file always did. Before, a value only given on the command line (--sample-rate 32000,--sample-rate 8000) was not checked. It now stops the start with the message that names the value. A systemd unit withSNAPDOG_ARGS="--sample-rate 32000"restarts in a loop until the argument is changed. The other choice, accepting more rates on the command line than in the file, would have made the same setting valid in one place and invalid in the other. -
A leading
~in a path means your home directory.state_dir,log_file, the Subsonic cache path and the TLS paths used to take~literally, as a directory named~under the working directory. State written there (zones.json,eq.json,server_id,airplay-pairing) is no longer read. If such a directory still exists, SnapDog says so at startup and in--check-config; move its contents to the real path. Writing the old literal path was never what anybody meant, so the literal reading was not kept. -
[spotify].namenames the Spotify Connect device. It used to be ignored (with a warning); the name in the Spotify app was always the zone'sspotify_name, by default the zone name. Now a zone without its ownspotify_nameis called[spotify].namewhen there is exactly one zone, and"<name> <zone name>"when there are several, because every zone runs its own device and Spotify lists devices by name. A zone's ownspotify_namealways wins, and without a name (or with a blank one) the zone name is used as before. This is visible for every configuration that already has the key, among them those the macOS Settings app wrote: the device names in the Spotify app change from the zone names to the new ones, and the Spotify app may list them as new devices (the Zeroconf id is derived from the name). Remove the key, or setspotify_nameper zone, to keep the old names. -
An empty encryption key is never a key.
SNAPDOG_SNAPCAST_ENCRYPTION_PSKset to nothing (for example${PSK:-}in a compose file whose secret did not arrive) used to be used as the key. It is now ignored with a warning, a key from the file stays, and withcodec = "f32lz4e"and no other key SnapDog refuses to start instead of falling back to the public built-in key. An emptyencryption_pskin the file is treated the same. Clients that were configured with an empty key need the real key, or the setting has to be removed. -
A WebSocket client that falls behind is closed with code 1013 (try again later), not 1001 (going away), which is for a server that shuts down. The web UI reconnects on any close and is not affected. A client that treated 1001 as "stop reconnecting" will now reconnect, which is what it should do for a lagging connection; one that expects 1001 for lag has to accept 1013.
-
The interactive API console at
/docsis off by default. It used to be on, and its page embedded the whole OpenAPI document without any API key. Set[http].api_console = trueto get it back (see "API documentation (OpenAPI)" below for what that costs). The OpenAPI document is served on its own atGET /api/v1/openapi.json, and that route needs the key whenapi_keysis set. -
Media API: playlists have a stable
playlist_id, andidis deprecated. Nothing breaks with this release. A client that reads or sends the playlistid, orplaylistin the WebSocket eventzone_changed, has to move toindex,playlist_indexandplaylist_idbefore SnapDog 1.0 removes them, which breaks compatibility of/api/v1for clients that still use them. The server now also sendsplaylists_changed, reads the Subsonic playlist list every[subsonic].refresh_secsseconds, and can create and edit Subsonic playlists through the API when[subsonic].playlist_write = true, which is off by default because authentication is optional. docs/upgrading.md has the details. -
KNX device mode: product database version 14. Zones get three seek objects, and Playlist Step Up/Down and Playlist Name swap their object numbers (36 and 37). If you programmed the device from ETS with 0.30.0, import the new
snapdog.knxprod, link those two objects again and download the device. docs/upgrading.md has the details, and this has not been tried in ETS with a real project yet. -
KNX device mode. A malformed
[knx].url, a malformed global group address (all_stop,knx_timeand the others) or an IPv6[knx].bindnow stops the start when the configuration loads, with the key named; before, the mistake surfaced later or never.knx-memory.binis format version 2: an older SnapDog cannot read it, starts the device unprogrammed and overwrites the file, so keep a copy before a downgrade. The device applies the parameters of the last ETS download (earlier releases kept applying those of the first one): check them in ETS after the upgrade. Behaviour changes: status is sent only when a value changes (a bus device that relied on the repeated send no longer gets it), a group response no longer runs a command, a command object no longer answers a read, and the group object flags are enforced. docs/knx-bridge.md has the details, and what has not been tried against ETS. -
MQTT. Inbound commands work again: since 0.16.0 every one was dropped, because the base topic's trailing slash was stripped twice. They are strict now:
on,off,true,false,1and0in any case, and finite numbers within limits; a retained command is ignored and a flood is rate limited per zone and client. Anything that relied on the lenient parsing, such as arepeatpayload other thanoff, changes. A wrong[mqtt]section (a value of the wrong type, an unknown key, a TLS or password file that cannot be read) switches MQTT off with an error while the rest of the server keeps running, and--check-configexits 1. Themedia_playerdiscovery messages, which Home Assistant never turned into an entity, are no longer published; they are deleted from the broker only withclear_legacy_topics = true. docs/mqtt.md has the migration notes. -
Listeners. Two services configured for the same port, or a
bindaddress that no interface of the machine has, stop the start with both names (--check-configreports the same); a second bind used to fail later. Spotify Connect listens on TCP port 7100 (zone n on 7100 + n - 1) instead of a random port: open it in the firewall, or set[spotify].port = 0for the old behaviour.[spotify].bindis refused, because the Spotify library cannot be bound to an address.[http].bindaccepts a list, and[network].bindsets the default of every service; see "Network ports and what they expose" below.
MQTT
Topics, all below base_topic ({n} is the 1-based position of the [[zone]] or [[client]]):
status:online/offline, retained.offlineis the last will, and is also published on a clean shutdown.zones/{n}/state,clients/{n}/state: retained JSON.zones/{n}/{volume,mute,control,shuffle,repeat,position,track,playlist}/set,zones/{n}/presence/{,enable/,timeout/}set,clients/{n}/{volume,mute,latency,zone}/set: commands.volumetakes0.0..1.0or0..100(so1is 100 %); booleans taketrue/false,on/off,1/0. A retained command is ignored.
After a reconnect SnapDog subscribes again and republishes availability and state. Commands are rate limited per zone and client.
broker takes host:port (plaintext) or mqtts://host:8883 (TLS 1.2 or newer, certificate and host name always verified, optional ca_file and mutual TLS). A wrong [mqtt] section switches MQTT off and is logged as an error, the rest of the server keeps running (snapdog --check-config reports it). Limits, error handling, broker settings and what changed from earlier releases: docs/mqtt.md. snapdog.example.toml describes every key and how to harden the broker (TLS-only listener, per-client credentials, ACL).
Home Assistant. Home Assistant's MQTT integration has no media_player platform, so a zone cannot appear as one (the discovery messages earlier releases published never produced an entity, and SnapDog now deletes them). What MQTT can express is available as opt-in discovery: set [mqtt.homeassistant] discovery = true and SnapDog announces one device with, per zone, volume, mute, shuffle, repeat, transport buttons, playback state, title, artist, album, duration, source, playlist and presence, and per client volume, mute, latency, zone and connection (48 entities for two zones and two clients). It is sent after every connect, when a client is added, and after Home Assistant's birth message, and discovery = false takes the entities out again. No cover art, no position bar and no tts.speak target: those need a real integration, see RFC 0007 and docs/mqtt.md.
API Authentication
If api_keys is set in [http], all /api/v1/* and /ws endpoints require authentication:
- REST:
Authorization: Bearer <key>header - WebSocket:
ws://host:port/ws?token=<key>query parameter; query tokens are accepted only on/ws - Health endpoints and the WebUI are always accessible
Without api_keys every endpoint is open, and SnapDog warns at startup when it listens on all interfaces.
API documentation (OpenAPI)
Builds with the api-docs feature, which the released binaries have, serve the OpenAPI document at
GET /api/v1/openapi.json. It is part of /api/v1: with api_keys set it needs the Bearer key, without keys
it is open like the rest of the API. Set [http].api_docs = false to switch it off. Builds without the
feature serve no API documentation at all.
The interactive console (Scalar) at /docs is off by default. [http].api_console = true turns it on. A
browser cannot send the Authorization header when it opens a page, so the console is not behind the API key,
and the page contains the whole OpenAPI document. The page and its script are served by SnapDog itself: a pinned,
vendored copy of Scalar (@scalar/api-reference 1.74.0, MIT, about 1.3 MB gzip-compressed in the binary, licence at
/docs/assets/NOTICE.md) at /docs/assets/scalar.js. No other host is contacted, so the console also works
without internet access, and a Content-Security-Policy keeps the page to its own origin. Scalar's fonts, telemetry,
AI agent and MCP features are switched off. The page runs on the same origin as the web UI, and SnapDog logs one
warning at startup when the console is on. Enable it only on a network you trust. The script is sent compressed
(Content-Encoding: gzip); a client that does not accept gzip gets 406, which no browser does.
curl -H "Authorization: Bearer $SNAPDOG_KEY" http://localhost:5555/api/v1/openapi.jsonNetwork ports and what they expose
The server prints the same list at start ("Listeners" in the log) and serves it as
GET /api/v1/system/listeners, with the real addresses and ports, so a firewall can be derived from a running
instance.
| Service | Default port | Protocol | bind support |
Set with | Protection | Firewall rule it implies |
|---|---|---|---|---|---|---|
| WebUI, REST API, WebSocket | 5555 ([http].port) |
TCP | full | [http].bind, [network].bind |
[http].api_keys, optional TLS |
Allow from the machines that use the web UI or API |
| Snapcast audio stream | 1704 ([snapcast].streaming_port) |
TCP | full, one address | [snapcast].streaming_bind, [network].bind with one address |
None | Allow from your Snapcast clients only: whoever can reach the port can connect as a client and receive the audio |
| Snapcast JSON-RPC | 1705 ([snapcast].jsonrpc_tcp_port) |
TCP | none | not configurable | None | Only in builds with the snapcast-process feature (released binaries use the embedded server and do not open it). Anyone who can reach it controls the server, and SnapDog's generated snapserver.conf does not restrict its address, so block it for everyone but this host |
| AirPlay | 7001 and up, one per zone (7000 + zone number) | TCP, plus UDP | partial with AirPlay 2 (feature ap2), none without |
[airplay].bind, [network].bind |
Optional [airplay].password |
Allow the TCP ports from your AirPlay devices. [airplay].bind does not reach the AirPlay 2 clock sockets: UDP 319 and 320 stay on all interfaces (shairplay 0.12), so limit them in the firewall. AirPlay also uses further UDP ports chosen at run time for audio and timing, so allow UDP from the same devices |
| Spotify Connect | 7100 and up, one per zone ([spotify].port + zone number − 1; 0 = random) |
TCP | none | not possible: [spotify].bind is an error |
The Spotify app's own login | Allow the ports from the phones that cast. Announcements follow [spotify].zeroconf_backend: libmdns announces on the default-route interface only, avahi and dns-sd on all |
| KNX/IP device mode | 3671 | UDP (and the KNX multicast group 224.0.23.12) | partial | [knx].bind names the interface for the multicast group |
None: KNX/IP has no authentication | The socket stays on all interfaces, so limit 3671/udp in the firewall |
| mDNS announcements | 5353 | UDP | partial | the HTTP bind: the announcement of SnapDog (_snapdog._tcp, _snapcast._tcp) follows it. The AirPlay announcement follows [airplay].bind (builds with ap2) on Linux and macOS; the address selects the interface, so other addresses of that interface are announced too. On Windows the native API honors the limit only in part |
None | Allow multicast DNS where the devices that discover SnapDog are |
| MQTT | none | Client only: SnapDog connects out to the broker, nothing listens |
"Full" means the service listens on exactly the configured addresses. "Partial" means the setting has an effect, but
not on every socket of the service. "None" means the service cannot be bound to an address, and SnapDog says so
instead of ignoring the setting. A [network].bind that cannot reach a service (Spotify) is reported at start. Inside a
bridged container the bind address is the container's, not the host's, so publish the port on one address
(-p 192.168.10.5:1704:1704) rather than relying on bind.
Choosing the addresses. [network].bind states once on which addresses SnapDog listens. A service's own
bind ([http].bind, [airplay].bind, [snapcast].streaming_bind, [knx].bind) wins; a service without one uses
[network].bind; without either, each service keeps its default (HTTP ::, the others every IPv4 interface).
An entry is an IP address, the word loopback (127.0.0.1 and ::1) or the name of a network interface. A name
stands for the IPv4 addresses of the interface and its IPv6 addresses except link-local (fe80::/10), read when
the file is loaded. An unknown name stops the start and lists the interfaces that exist. [http].bind and
[airplay].bind take a list. The Snapcast
streaming port and the KNX device server listen on exactly one address: a name works for them only if the
interface has exactly one IPv4 address (otherwise the message lists the addresses and says what to write), and
they use [network].bind only when it resolves to one address, and SnapDog warns (and keeps their default) when it holds more, instead of taking the first.
[knx].bind names the IPv4 interface that joins the KNX multicast group; UDP port 3671 itself stays on all
interfaces, because the KNX library binds it that way. [airplay].bind has an effect only in builds with
AirPlay 2 (feature ap2), and even there the clock sockets 319 and 320 stay on all interfaces. Spotify Connect cannot be bound to an address. bind is not a security boundary:
use the firewall as well. At start, two services on the same port, or an address that no interface has, stop
SnapDog with a message that names them; --check-config reports the same.
| Situation | Setting |
|---|---|
| One network card | nothing |
| Audio and API on the LAN card only, host has a second card | [network] bind = ["eth0"] |
| API behind a reverse proxy on the same host | [http] bind = "loopback" |
| API on the LAN and on the Nebula overlay, audio on the LAN only | [network] bind = ["eth0"], [http] bind = ["eth0", "nebula1"] |
| VLAN interface | [network] bind = ["enp1s0.16"] |
| Docker with a bridge | publish the port on one address (-p 192.168.10.5:1704:1704); names inside the container are the container's |
| Docker with host networking | as on the host |
A bind on the HTTP API also limits the mDNS announcement of SnapDog to the interfaces that carry those
addresses, so a service does not listen on eth0 and announce itself on nebula1. The announcement stays on every
interface if the HTTP API or the announced Snapcast stream has no bind. The AirPlay announcement is made by the
AirPlay library and still goes out on every interface (Linux) or through Bonjour (macOS).
In the table, (via eth0) after a row says that the address came from the entry eth0. The API serves it as via.
snapdog --check-config prints the interfaces of the machine with their addresses, then the listeners, so the
output answers what to write.
Port 1704 has no authentication, in SnapDog as in Snapcast. To limit it:
[snapcast].streaming_bind = "192.168.10.5"makes SnapDog listen on that one address instead of all interfaces (one address, IPv4 or IPv6;127.0.0.1for clients on the same host only). It is also written to the generatedsnapserver.confin builds that drive an externalsnapserver.- Block the port in the firewall for every network that is not yours. In Docker, publish it on one
address (
-p 192.168.10.5:1704:1704) and leavestreaming_bindunset, because inside the container the address is the container's. - If the stream must not be readable on the network, use
codec = "f32lz4e"with your ownencryption_pskand pass the same key to every client (--encryption-psk). SnapDog warns whenf32lz4eruns with the built-in default key, which is public. - Clients that find the server with mDNS connect to the address mDNS resolves, so the bound address must be reachable for them.
snapdog --config snapdog.toml --check-config prints a note while the port listens on all interfaces, refuses a
streaming_bind that is not an address, and warns when the address belongs to no network interface of the machine
it runs on (a best-effort check: it only reports the operating system's "cannot assign requested address", so it is
a warning, not an error, because the file may be checked on another host). It does not bind the port itself. At
start, an unassignable address stops SnapDog with a message that says so; a port that is already in use says that
instead.
Log file
[system].log_file writes the log to a file in addition to stdout, one file per day (snapdog.log becomes
snapdog.2026-10-26.log). SnapDog keeps 14 daily files, the current one and 13 more, and deletes older ones when
it starts and then hourly. It deletes only files named exactly snapdog.<yyyy-mm-dd>.log, the oldest date first;
everything else in the directory, such as snapdog-audit.log, is left alone. Days change at midnight UTC,
whatever the local time zone. An error while writing (disk full) is not reported; stdout keeps working.
The packaged systemd unit creates /var/log/snapdog for this (LogsDirectory=).
Subsonic Server Notes
When using Navidrome, ensure transcoding is configured for the format specified in format. Without transcoding, files in non-streamable containers (ALAC/AAC in MP4) will be downloaded fully before playback, causing significant latency.
SnapDog advertises _snapdog._tcp via the OS DNS-SD daemon (Avahi on Linux, mDNSResponder on macOS). Clients discover the server automatically on the local network.
Advertised service:
| Field | Value |
|---|---|
| Service type | _snapdog._tcp |
| Port | REST API port (default 5555) |
TXT api_version |
1 |
TXT server_id |
Persistent UUID v4 — identifies the same server across interfaces/restarts |
TXT snapcast_port |
Binary audio streaming port (default 1704) |
TXT auth |
true if API keys are configured |
TXT tls |
true if HTTPS is enabled (only present when active) |
TXT docker |
true if running in a container (only present when detected) |
TXT base_url |
Canonical server URL (see below) |
base_url resolution: The base_url TXT record is included when the server is not reachable on all interfaces. Clients should prefer base_url over the mDNS hostname when present:
base_urlexplicitly configured → use configured value (reverse proxy, custom domain)[http].bindset to a specific IP (not::or0.0.0.0) → auto-derived ashttp://{bind}:{port}- Otherwise →
base_urlomitted; clients use the mDNS hostname + port directly
Optionally, _snapcast._tcp can be advertised on the streaming port for standard Snapcast client compatibility:
[mdns]
advertise_snapcast = trueOn Linux, SnapDog registers an MPRIS2 D-Bus interface per zone. This enables desktop media widgets, playerctl, and Bluetooth AVRCP control.
$ playerctl -l
snapdog.zone1
snapdog.zone2
$ playerctl -p snapdog.zone1 metadata
$ playerctl -p snapdog.zone1 play
$ playerctl -p snapdog.zone1 volume 0.8
Each zone appears as org.mpris.MediaPlayer2.snapdog.zone{N}. SnapDog uses the session bus when DBUS_SESSION_BUS_ADDRESS is set and the system bus otherwise, which is the case for the systemd service. The system bus only lets a process own a name that a policy allows, so the .deb, AUR and SnapDog OS packages install one to /usr/share/dbus-1/system.d/ (snapdog.conf for the server, snapdog-client.conf for the client). If you install the binaries by hand, copy the files from packaging/ in the release archive there as well.
The policy lets every local user read the players and control them, which is what playerctl and media keys do. It allows sending to each bus name one by one instead of by prefix, because dbus-broker ignores prefix rules for sending and would deny every caller. The server's file covers zones 1 to 16 (the plain name and .instance2 to .instance10 for each). For a zone above 16 the server logs a warning, and nobody can call its player on the system bus until you add its names to snapdog.conf.
The same rule also lets every local user own those names, not only the service, because the service runs as a systemd DynamicUser and its uid is not known to the bus when it reads the policy. A local user who takes a name before the service asks for it (at boot, or right after a restart) makes the service fall back to the next .instanceN name, and calls to the plain name then reach that user. They can misdirect or swallow media key commands and show wrong metadata, but they only receive MPRIS calls and nothing secret. If you cannot accept that, run the service with a fixed User= instead of DynamicUser=yes and move the own_prefix rule in the policy file into a <policy user="..."> element for that user. Disable with:
[dbus]
enabled = falseSnapDog extends the Snapcast binary protocol with custom messages for metadata and playback control:
| Type | Direction | Purpose |
|---|---|---|
| 10 | Server → Client | EQ configuration |
| 11 | Server → Client | Speaker correction profile |
| 12 | Server → Client | Fade-out trigger |
| 13 | Client → Server | Playback controls (play/pause/next/seek/shuffle/repeat) |
| 14 | Server → Client | Full zone metadata (track, playback state, volume) |
| 15 | Server → Client | Cover art binary (JPEG/PNG) |
Standard Snapcast clients ignore these messages. Only snapdog-client sends/receives them.
SnapDog builds on a family of Rust crates:
| Crate | Description |
|---|---|
| snapcast-server | Embeddable Snapcast server with per-stream codecs, custom protocol, encryption |
| shairplay-rust | AirPlay 1 + 2 receiver library (RAOP/AirTunes) |
| knx-rs | KNX protocol stack — core types, KNXnet/IP, device stack, TP-UART, .knxprod generator |
| snapdog-common | Shared types and constants between server and client (EQ, protocol IDs, volume curve) |
A specialized Snapcast client that understands SnapDog's custom protocol extensions:
-
F32+LZ4 codec — lossless 32-bit float audio with LZ4 compression (not supported by stock snapclients)
-
Per-client parametric EQ — receives EQ curves via custom protocol, applies biquad filters before output
-
Speaker correction — second EQ stage for Spinorama-based speaker profiles
-
Audio fade — smooth fade-out/fade-in on zone switch (triggered by server)
-
Hardware volume — native ALSA mixer control with perceptual (quadratic) curve
-
MIDI CC volume — send volume as MIDI Control Change (e.g., for professional mixing consoles)
# Send volume on MIDI channel 1, CC7 (default) to a USB MIDI interface snapdog-client --mixer midi:hw:1:0 # Send volume on MIDI channel 3, CC11 (expression) to a named port snapdog-client --mixer midi:"Scarlett 18i8":2:11
-
Behringer X32 / Midas M32 volume — move channel faders over OSC on the network, no MIDI interface needed. The volume is the fader position over the whole travel, the same scale
--mixer midigives on the X32: 100 % is the top (+10 dB), 75 % is 0 dB. To stop at unity, set the client'smax_volumeto 75. Mute switches the channel off, but only when SnapDog itself mutes or unmutes: a plain volume change never re-opens a channel someone muted at the desk. Every write is read back and repeated if the desk lost it, and a desk that boots later still receives the volume.Changes made at the desk flow back to SnapDog: the client reads the faders twice a second, and a moved fader or a channel switched off becomes the client's volume or mute in SnapDog, the app and every integration. The other named channels follow the moved one. SnapDog still sets the desk when the client starts, and the fader cannot stay above the client's maximum volume: SnapDog moves it back. In absolute group volume mode a client has no volume of its own, so the desk fader moves the zone.
--mixer-sync outturns the reverse channel off; changes at the desk then stay until SnapDog sends the next volume.# Stereo pair on input channels 31 and 32 (both are written, whatever the desk's link covers) snapdog-client --mixer x32:192.168.1.20:31,32 # One channel, OSC on a non-default port (the X32 itself always uses 10023) snapdog-client --mixer x32:x32.local:5:10023 # SnapDog sets the faders, the desk does not report back snapdog-client --mixer x32:192.168.1.20:31,32 --mixer-sync out
-
Encryption — PSK-based chunk encryption matching the embedded server
Available as binary and Docker image (ghcr.io/snapdogrocks/snapdog-client).
┌─────────────────────────────────────────────────────┐
│ SnapDog │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ ZonePlayer│ │ ZonePlayer│ │ ZonePlayer│ ... │
│ │ (tokio) │ │ (tokio) │ │ (tokio) │ │
│ └────┬──────┘ └────┬──────┘ └────┬──────┘ │
│ │ │ │ │
│ ┌────┴──────────────┴──────────────┴─────┐ │
│ │ Embedded Snapcast Server │ │
│ │ (per-zone streams + encoders) │ │
│ └───────────────────┬────────────────────┘ │
│ │ │
│ ┌─────────┐ ┌──────┴────┐ ┌──────────┐ │
│ │ AirPlay │ │ REST API │ │ MQTT │ │
│ │receivers│ │ + WebUI │ │ bridge │ │
│ └─────────┘ └───────────┘ └──────────┘ │
│ ┌──────────┐ │
│ │ KNX │ │
│ │ bridge │ │
│ └──────────┘ │
└─────────────────────────────────────────────────────┘
│
┌────┴──────┐
│snapclients│
│(per room) │
└───────────┘
- ZonePlayer — per-zone tokio task, owns audio pipeline (decode → resample → encode → Snapcast)
- Dual Snapcast backend — embedded server (default) or external process via JSON-RPC
- Volume via Snapcast — never PCM amplitude scaling, full dynamic range preserved
- MAC-based client matching — clients auto-assigned to zones from config
Release binaries and the published container images are built with full, which enables every
feature below except the alternative snapcast-process engine. A plain cargo build uses the default set.
| Feature | Default | Description |
|---|---|---|
snapcast-embedded |
✅ | In-process Snapcast server (snapcast-server) |
snapcast-process |
— | External snapserver binary + JSON-RPC |
ap2 |
— | AirPlay 2 (encrypted transport, HAP pairing) |
dbus |
✅ | MPRIS2 D-Bus interface per zone (Linux only) |
spotify |
✅ | Spotify Connect receiver (librespot) |
api-docs |
— | OpenAPI document at /api/v1/openapi.json (behind the API key) and the optional API console (Scalar) at /docs, which is off unless [http].api_console = true |
full |
— | snapcast-embedded, ap2, dbus, spotify and api-docs: what a release carries |
ap2 is what makes [airplay].mode = "airplay2" take effect. Release builds carry it, so AirPlay 2
is the default there and mode = "airplay1" is the way back. Without ap2 the setting is ignored and
the receiver is AirPlay 1 only. What changes for an existing installation is in the
upgrade notes.
See Architecture Decision Records for design rationale.
make setup # Git hooks
docker compose -f docker-compose.dev.yml up -d # Dev infrastructure
cargo run -- --config snapdog.dev.toml # Run
cargo xtask ci # Run all CI checks locally
cargo test # Test
cargo clippy -- -D warnings # Lint
cargo run --package xtask -- gen-api-spec openapi.json # Refresh API contractopenapi.json is the committed REST contract. The OpenAPI contract test rejects
Rust API changes until this snapshot is regenerated; after merge, automation
opens an auto-merge PR with the updated reference in
snapdog-web.
Dev Infrastructure (Docker Compose)
| Service | Purpose |
|---|---|
| 3× snapclient | Simulated rooms (Living Room, Kitchen, Bedroom) |
| mqtt | Mosquitto MQTT broker |
| knxd | KNX gateway simulator |
| knx-monitor | Visual KNX bus debugging |
| navidrome | Subsonic-compatible music server |
GPL-3.0-only. Contributions are accepted under the Contribution Licence (Deutsch); see CONTRIBUTING.md.
If SnapDog is useful to you, consider buying me a coffee ☕