Skip to main content
SnapDog

pysnapdog: async Python client for the SnapDog multi-room audio server

Typed. Fully asynchronous. Built for Home Assistant, usable everywhere.

CI License: Apache-2.0 Python 3.14+ Typed


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

  • SnapDog — the server this library talks to.
  • snapdog-homeassistant — the Home Assistant integration built on this library (in preparation).

License

Apache License 2.0.

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)

Source distribution for pysnapdog 0.3.0
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)

Table of built distributions (wheels) for pysnapdog 0.3.0
File Interpreter ABI Platform Actions
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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page