How the Discovery Controller Uses mDNS/Zeroconf for Player Discovery in Music Assistant

The DiscoveryController manages a shared AsyncZeroconf instance to broadcast the Music Assistant server via mDNS, browse for player services, and dispatch state changes to providers with serialized handling and automatic cache replay.

The DiscoveryController in the music-assistant/server repository orchestrates all mDNS/Zeroconf-based player discovery. It centralizes Zeroconf lifecycle management into a single async-aware component that handles both outgoing server announcements and incoming device detection across the local network.

Initializing the Shared AsyncZeroconf Instance

When the controller starts, it constructs a single AsyncZeroconf object that the entire application shares. In music_assistant/controllers/discovery/controller.py, the _create_aiozc method builds this instance using interface configuration from the core config (CONF_ZEROCONF_INTERFACES).

The helper get_zeroconf_args (located in music_assistant/helpers/util.py) generates the correct ip_version and interfaces arguments based on user preferences. This allows administrators to restrict discovery to specific network interfaces, IPv4-only, or IPv6-only environments. The resulting object is stored as self._aiozc and exposed through the aiozc property for all consumers, preventing duplicate network sockets and keeping the mDNS cache centralized.

Broadcasting the Server via mDNS

The controller announces the Music Assistant instance itself to the network. The _register_mass_service method registers a _mass._tcp.local. service, enabling other players and Home Assistant to discover the MA server via standard mDNS browsing. This self-announcement ensures the server is visible as a first-class citizen on the local network.

Browsing for Player Services

All provider manifests declaring mdns_discovery are collected during startup. The _setup_mdns_browser method creates a single AsyncServiceBrowser that listens for the union of service types defined across all providers. This global browser routes every mDNS state change—whether a service appears, updates, or disappears—to the controller's centralized handler.

Dispatching Events to Providers

When the browser detects a change, _on_mdns_service_state_change iterates over loaded providers and matches the service type to the appropriate handler. The controller maintains per-provider asyncio.Lock instances (self._mdns_locks) to serialize concurrent state changes, preventing race conditions when multiple mDNS events arrive rapidly.

If the state is ServiceStateChange.Added, the provider receives the AsyncServiceInfo to register the player. If ServiceStateChange.Removed, the provider unregisters it. This architecture ensures that network fluctuations do not corrupt the internal player registry.

Replaying Cached Discovery Results

Providers loaded after startup (or reloaded during runtime) must still discover devices already present on the network. The _replay_mdns_discovery method addresses this by iterating through the Zeroconf cache (self.aiozc.zeroconf.cache.cache) and replaying any cached entries matching the provider's declared service types. This guarantees that a provider never misses a device simply because it loaded after the initial mDNS announcement.

Looking Up Services by Name

For providers that need to resolve a specific device by name, the async_find_mdns_service method offers a targeted query API. This method first checks the Zeroconf cache for an exact match, then optionally waits for a new mDNS event if the device is not yet cached. It uses the shared aiozc instance and returns the full AsyncServiceInfo record, allowing providers to extract IP addresses and other metadata.

Implementation Example for Providers

Providers interact with the discovery controller through standardized callbacks and helper methods. Below are typical implementation patterns from the codebase.

Requesting a player by service name:

async def get_player_by_name(self, name: str) -> Player | None:
    # Ask the discovery controller for an mDNS service matching the device name

    info = await self.mass.discovery.async_find_mdns_service(
        service_type="_raop._tcp.local.",  # RAOP (AirPlay) service

        name_filter=name,
        timeout=5.0,
    )
    if info is None:
        return None
    # Extract the IP address from the Zeroconf info

    ip = get_primary_ip_address_from_zeroconf(info)
    return Player(name=name, ip_address=ip)

Handling state change callbacks:

async def on_mdns_service_state_change(
    self, name: str, state: ServiceStateChange, info: AsyncServiceInfo | None
) -> None:
    if state is ServiceStateChange.Added and info:
        ip = get_primary_ip_address_from_zeroconf(info)
        self.register_player(name, ip)
    elif state is ServiceStateChange.Removed:
        self.unregister_player(name)

Both examples rely on get_primary_ip_address_from_zeroconf from music_assistant/helpers/util.py to parse the raw Zeroconf data into usable IP addresses.

Summary

  • Single Shared Instance: The controller creates one AsyncZeroconf object (self._aiozc) that powers all discovery, server registration, and DNS resolution to avoid socket duplication.
  • Service Type: Music Assistant broadcasts itself as _mass._tcp.local. and browses for service types declared in provider manifests.
  • Serialized Dispatch: Per-provider asyncio.Lock instances ensure thread-safe handling of concurrent mDNS events through _on_mdns_service_state_change.
  • Cache Replay: The _replay_mdns_discovery mechanism ensures late-loading providers receive notifications for devices already present on the network.
  • Targeted Lookup: async_find_mdns_service provides synchronous-style lookups with cache-first resolution and optional waiting for new announcements.

Frequently Asked Questions

What mDNS service type does Music Assistant broadcast?

Music Assistant registers itself as a _mass._tcp.local. service via the _register_mass_service method in music_assistant/controllers/discovery/controller.py. This allows other devices and Home Assistant to discover the MA server on the local network without manual configuration.

How does the controller handle providers that load after devices are already discovered?

The _replay_mdns_discovery method iterates through the Zeroconf cache and replays cached entries matching the provider's service types. This ensures providers immediately learn about existing network devices regardless of when they were loaded relative to the initial mDNS announcements.

Is the Zeroconf instance shared across the entire application?

Yes. The DiscoveryController creates a single AsyncZeroconf instance stored in self._aiozc and exposed through the aiozc property. All providers, the server registration logic, and helper utilities like async_find_mdns_service consume this same instance to maintain a centralized cache and prevent duplicate network sockets.

How does the controller ensure thread-safe handling of mDNS events?

The controller maintains a dictionary of per-provider asyncio.Lock instances (self._mdns_locks). When _on_mdns_service_state_change dispatches an event to a provider, it acquires that provider's specific lock, guaranteeing serialized access even when multiple mDNS state changes occur simultaneously.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →