pysnapdog: async Python client for the SnapDog multi-room audio server
Typed. Fully asynchronous. Built for Home Assistant, usable everywhere.
pysnapdog talks to a SnapDog server over its REST API
and, as the library grows, over its WebSocket. It is the client library behind the SnapDog
integration for Home Assistant, and it has no Home Assistant
dependency of its own: you hand it an aiohttp session and it hands you typed objects.
Features
| ⚡ Async only | Built on aiohttp. You own the session, the library never opens or closes one. |
| 🧩 Typed models | Frozen dataclasses with slots. A response that lacks a field raises an error instead of producing a half-filled object. |
| 📡 Live updates | SnapDogListener keeps the WebSocket open, reconnects with a growing wait, and delivers events only after your initial read, so nothing is lost in between. |
| 📜 Checked against the contract | The tests run against a fake server built from the server's own openapi.json and asyncapi.json, which are pinned in spec/. |
| 🔐 API key in the header | Authorization: Bearer, never in a URL. TLS with or without certificate verification. |
| 🧯 One error family | SnapDogConnectionError, SnapDogAuthError, SnapDogResponseError (with SnapDogNotFoundError, SnapDogUnsupportedError, SnapDogBusyError), all derived from SnapDogError. |
| 🧪 Strict typing | mypy --strict clean, ships py.typed. |
| 🪶 No extra dependencies | aiohttp and nothing else. |
Installation
pip install pysnapdog
Requires Python 3.14 or newer, the version Home Assistant 2026.10 runs on.
Quick start
import asyncio
import aiohttp
from pysnapdog import SnapDogClient
async def main() -> None:
async with aiohttp.ClientSession() as session:
client = SnapDogClient(session, "snapdog.local", api_key="…")
version = await client.version()
print(f"{version.name} {version.version}")
for zone in await client.zones():
print(f"{zone.index}: {zone.name} ({zone.playback}, volume {zone.volume})")
asyncio.run(main())
api_key is only needed when the server has api_keys configured. For a server behind TLS pass
tls=True, and verify_tls=False for a self-signed certificate.
Control a zone
Indexes of zones and clients start at 1, as in the server's API. Zone commands return nothing, the new state arrives as an event. Calls on clients return the new value.
await client.set_zone_volume(1, 35) # absolute, or "+5" / "-3" for relative
await client.play_playlist_id(1, "subsonic:42") # remember playlists by playlist_id, not by index
await client.set_zone_repeat(1, RepeatMode.PLAYLIST)
await client.apply_zone_eq_preset(1, "rock")
await client.set_client_zone(3, 2) # move client 3 into zone 2
cover = await client.zone_cover(1) # Image(content=b"...", content_type="image/jpeg")
A playlist has a stable playlist_id (snapdog:radio or subsonic:<id>) and a unified index that moves
when playlists or radios are added or removed. Store and compare the playlist_id, and reload
client.playlists() on the PlaylistsChanged event and after a reconnect. Against a server that is too old
to send playlist_id, it is None and the models take index from the deprecated id field.
Search and manage playlists
Search needs a Subsonic server behind SnapDog. limit and offset apply to each kind, and every result
has the identifier of its own kind. Writing playlists needs [subsonic].playlist_write = true on the
server, otherwise the calls raise SnapDogReadOnlyError. Address a playlist by its playlist_id.
from pysnapdog import SearchKind
result = await client.search("elton", kinds=[SearchKind.ALBUM, SearchKind.TRACK], limit=10)
for item in result.items:
print(item.kind, item.title, item.subtitle)
await client.play_subsonic_album(1, result.items[0].album_id) # an album plays as its own queue
cover = await client.media_cover(result.items[0].cover_id)
playlist = await client.create_playlist("Evening", ["track-id-1", "track-id-2"])
await client.update_playlist(playlist.playlist_id, name="Late", add_track_ids=["track-id-3"])
await client.replace_playlist_tracks(playlist.playlist_id, ["track-id-3", "track-id-1"])
await client.delete_playlist(playlist.playlist_id)
The server refuses snapdog:radio with the same SnapDogReadOnlyError, because that list lives in its
configuration file. When the Subsonic server itself says no, for example for a user without the right to
edit, the error is SnapDogUpstreamError with its message in detail.
Live updates
The server sends no initial state. SnapDogListener opens the socket first, lets you read the state
over REST, and only then delivers the events that arrived meanwhile, in order. It does the same again
after every reconnect.
from pysnapdog import SnapDogListener, ZoneChanged, ZoneVolumeChanged
zones: dict[int, Zone] = {}
async def load() -> None:
zones.update({z.index: z for z in await client.zones()})
async def on_event(event) -> None:
if isinstance(event, ZoneVolumeChanged):
print(f"zone {event.zone}: volume {event.volume}, muted {event.muted}")
listener = SnapDogListener(client, on_event=on_event, on_connected=load)
listener.start() # from a running event loop
...
await listener.stop()
Events carry absolute values, never a delta. A client that falls behind is closed by the server with
code 1013, and the listener reconnects and calls on_connected again. A rejected API key ends the
listener, because retrying cannot help: the error is in listener.stopped_reason.
Errors
from pysnapdog import (
SnapDogAuthError,
SnapDogConnectionError,
SnapDogError,
SnapDogUnsupportedError,
) # also SnapDogReadOnlyError (403) and SnapDogUpstreamError (502), see above
try:
eq = await client.client_eq(3)
except SnapDogAuthError:
... # the key is missing or wrong
except SnapDogUnsupportedError:
... # HTTP 422: the client is not a SnapDog client, so it has no EQ
except SnapDogConnectionError:
... # the server is away, or the connection broke off
except SnapDogError:
... # anything else the library raises
SnapDogResponseError carries status and the server's own message in detail.
What is covered
| Area | Status |
|---|---|
| System: version, status, listeners, self-update | ✅ |
| Zones: transport, volume, mute, shuffle, repeat, seek, presence | ✅ |
| Zones: playlists, play track, playlist, Subsonic track, URL, cover | ✅ |
| Clients: volume, mute, latency, zone, name | ✅ |
| Equalizer for zones and clients, presets, speaker correction | ✅ |
| Media: playlists, tracks, covers, search, album playback | ✅ |
| Media: create, rename, fill, reorder and delete Subsonic playlists | ✅ |
KNX programming mode, product info, .knxprod download |
✅ |
| WebSocket: seven event types, reconnect, resync | ✅ |
The server has 112 REST operations. Every one is either a library call that a test exercises, or on a
short list of single-field reads that the Zone, Client, TrackMetadata and ZonePlaylist models
already carry. scripts/check_coverage.py compares the library with the pinned openapi.json and
asyncapi.json and fails when an operation or event is neither covered nor excluded with a reason.
CI runs it, so the gap cannot grow unnoticed.
Development
The project uses uv.
git clone https://github.com/SnapDogRocks/pysnapdog.git
cd pysnapdog
uv sync
uv run ruff check . # lint
uv run ruff format --check . # format
uv run mypy # types, strict
uv run pytest --cov # tests, coverage floor 95 %
uv run python scripts/check_coverage.py # every operation and event of the server
Commits follow Conventional Commits. A local hook checks
them once you run lefthook install (see CONTRIBUTING.md).
Related projects
- SnapDog — the server this library talks to.
- snapdog-homeassistant — the Home Assistant integration built on this library (in preparation).
License
Metadata
Release files for pysnapdog 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| pysnapdog-0.3.0.tar.gz | 143.7 kB | Without provenance | Details for pysnapdog-0.3.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| pysnapdog-0.3.0-py3-none-any.whl | Python 3 | none | any | Details for pysnapdog-0.3.0-py3-none-any.whl |
Total release size: 172.4 kB
Release files / pysnapdog-0.3.0.tar.gz
| Download URL | pysnapdog-0.3.0.tar.gz |
|---|---|
| Size | 143.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
21b067cd1ff4effbd4d800166da949dc1704b05836c1b9acac7aebeb9b098b33
|
|
BLAKE2b-256 checksum How to use checksums |
f80775dd5736fdc3470d7e20b68dc5f98f83deae8218e2d8c78a55cfa1ddc2ab
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.7 {"installer":{"name":"uv","version":"0.13.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / pysnapdog-0.3.0-py3-none-any.whl
| Download URL | pysnapdog-0.3.0-py3-none-any.whl |
|---|---|
| Size | 28.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7a5f91db627f9b9df9459ad92e265c7115f934fd3355805a573280798f6fcab5
|
|
BLAKE2b-256 checksum How to use checksums |
876e91ae59e01238f66ca55489d9e687ab34bbc54c93de56820bb8ce01eaf985
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.7 {"installer":{"name":"uv","version":"0.13.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for pysnapdog 0.3.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.