Skip to content
SnapDogRocksPublic

About

Multi-zone audio controller with AirPlay, Spotify, Subsonic, MQTT and KNX integration

Topics

Resources

Contributing

Security policy

Stars

10 stars

Watchers

0 watching

Forks

Latest commit

 

History

1,307 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SnapDog

Multi-room audio system with smart home integration

One binary. AirPlay + Spotify + Subsonic + MQTT + KNX. Snapcast-based audio delivery.

CI Release GitHub Release License: GPL-3.0 Docker


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.

Features

🔊 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)

Quick Start

Docker

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:latest
Docker 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 config

Published 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.com

For 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).

KNX Device Mode (no config file needed)

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

Binary

Download from Releases, verify the measured digest, then run it:

sha256sum --check SHA256SUMS --ignore-missing
snapdog --config snapdog.toml

On 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 SnapDog

Debian/Ubuntu (APT)

Packages 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: 1B7B79417383648BBFBE282E01AB8296EF0FCD76

That 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-client

Do not add trusted=yes. It disables exactly the check the fingerprint above is for.

What the packages do

The packages install the systemd units and manage them on install, upgrade and removal.

  • Fresh install: snapdog-client is enabled and started. It finds its server through mDNS. snapdog is installed disabled, because it needs a configuration first. Edit /etc/snapdog/snapdog.toml, then run sudo 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 snapdog stays stopped, and a disabled or masked snapdog-client stays that way. A snapdog-client that is enabled but stopped is started again, because the package starts it on every upgrade.
  • Removal: both services are stopped. If snapdog was 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 snapdog keeps in /var/lib/snapdog, such as pairing keys and the ETS and EQ settings. If snapdog still runs, because a policy-rc.d on the host keeps packages from stopping services, the directory is kept and the purge says so. Stop the service and delete /var/lib/private/snapdog and /var/lib/snapdog yourself 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.

Moving from the old repository

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

Then follow the steps above. The package names do not change, so an installed system upgrades in place.

Homebrew (macOS)

brew tap snapdogrocks/tap
brew install snapdog
brew install snapdog-client

Arch Linux (AUR)

yay -S snapdog          # or: yay -S snapdog-client

Default configuration of the packages

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

macOS App

Download SnapDog-Server-*.dmg from Releases. The menu bar app embeds the server binary, manages start/stop, and includes Sparkle auto-update.

From Source

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

Access:

WebUI http://localhost:5555
API http://localhost:5555/api/v1/zones
Health http://localhost:5555/health
WebSocket ws://localhost:5555/ws

Configuration

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.

Upgrading: changes that can stop a start or move data

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-rate accepts 44100, 48000, 88200, 96000, 176400 and 192000, --bit-depth 16, 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 with SNAPDOG_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].name names the Spotify Connect device. It used to be ignored (with a warning); the name in the Spotify app was always the zone's spotify_name, by default the zone name. Now a zone without its own spotify_name is called [spotify].name when 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 own spotify_name always 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 set spotify_name per zone, to keep the old names.

  • An empty encryption key is never a key. SNAPDOG_SNAPCAST_ENCRYPTION_PSK set 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 with codec = "f32lz4e" and no other key SnapDog refuses to start instead of falling back to the public built-in key. An empty encryption_psk in 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 /docs is off by default. It used to be on, and its page embedded the whole OpenAPI document without any API key. Set [http].api_console = true to get it back (see "API documentation (OpenAPI)" below for what that costs). The OpenAPI document is served on its own at GET /api/v1/openapi.json, and that route needs the key when api_keys is set.

  • Media API: playlists have a stable playlist_id, and id is deprecated. Nothing breaks with this release. A client that reads or sends the playlist id, or playlist in the WebSocket event zone_changed, has to move to index, playlist_index and playlist_id before SnapDog 1.0 removes them, which breaks compatibility of /api/v1 for clients that still use them. The server now also sends playlists_changed, reads the Subsonic playlist list every [subsonic].refresh_secs seconds, 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_time and the others) or an IPv6 [knx].bind now stops the start when the configuration loads, with the key named; before, the mistake surfaced later or never. knx-memory.bin is 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, 1 and 0 in 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 a repeat payload other than off, 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-config exits 1. The media_player discovery messages, which Home Assistant never turned into an entity, are no longer published; they are deleted from the broker only with clear_legacy_topics = true. docs/mqtt.md has the migration notes.

  • Listeners. Two services configured for the same port, or a bind address that no interface of the machine has, stop the start with both names (--check-config reports 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 = 0 for the old behaviour. [spotify].bind is refused, because the Spotify library cannot be bound to an address. [http].bind accepts a list, and [network].bind sets 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. offline is 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. volume takes 0.0..1.0 or 0..100 (so 1 is 100 %); booleans take true/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.json
Network 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.1 for clients on the same host only). It is also written to the generated snapserver.conf in builds that drive an external snapserver.
  • 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 leave streaming_bind unset, 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 own encryption_psk and pass the same key to every client (--encryption-psk). SnapDog warns when f32lz4e runs 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.

mDNS / Bonjour Discovery

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_url explicitly configured → use configured value (reverse proxy, custom domain)
  • [http].bind set to a specific IP (not :: or 0.0.0.0) → auto-derived as http://{bind}:{port}
  • Otherwise → base_url omitted; 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 = true

D-Bus / MPRIS2

On 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 = false

Custom Protocol (snapdog-client ↔ server)

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

Ecosystem

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)

snapdog-client

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 midi gives on the X32: 100 % is the top (+10 dB), 75 % is 0 dB. To stop at unity, set the client's max_volume to 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 out turns 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).

Architecture

┌─────────────────────────────────────────────────────┐
│                     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

Feature Flags

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.

Development

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 contract

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

License

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 ☕

About

Multi-zone audio controller with AirPlay, Spotify, Subsonic, MQTT and KNX integration

Topics

Resources

Contributing

Security policy

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages