# How the Music Assistant Discovery Controller Finds Devices Using mDNS

> Learn how the Music Assistant Discovery Controller uses mDNS for efficient device discovery. Explore Zeroconf advertisements and service lookups for seamless integration.

- Repository: [Music Assistant/server](https://github.com/music-assistant/server)
- Tags: internals
- Published: 2026-06-13

---

**The DiscoveryController creates a single AsyncServiceBrowser that aggregates mDNS service types from all provider manifests, listens for Zeroconf advertisements on the network, and forwards resolved AsyncServiceInfo objects to providers via the on_mdns_service_state_change callback, while also supporting synchronous-style device lookups through async_find_mdns_service.**

The Music Assistant server relies on mDNS (Zeroconf) to automatically discover network players like Sonos, AirPlay, and Spotify Connect devices. The **DiscoveryController** in [`music_assistant/controllers/discovery/controller.py`](https://github.com/music-assistant/server/blob/main/music_assistant/controllers/discovery/controller.py) centralizes this discovery logic, eliminating duplicate network traffic by maintaining one browser instance that serves all providers. This architecture ensures thread-safe handling of concurrent device announcements while providing both push-based callbacks and pull-based lookup methods for providers.

## The Three-Phase mDNS Discovery Workflow

The DiscoveryController implements a three-phase architecture that separates browser initialization, event handling, and on-demand resolution.

### Phase 1: Service Type Aggregation and Browser Setup

When the controller starts, the `setup()` method scans every registered provider's manifest for **mdns_discovery** entries. These entries declare which service types the provider can handle, such as `_raop._tcp.local.` for AirPlay or `_sonos._tcp.local.` for Sonos devices.

```python
all_types: set[str] = set()
for manifest in self.mass.get_provider_manifests():
    if manifest.mdns_discovery:
        all_types.update(manifest.mdns_discovery)

```

If the aggregated set contains service types, the controller initializes a single `AsyncServiceBrowser` from the `zeroconf` library. This browser listens for all declared service types simultaneously, registering the `_on_mdns_service_state_change` handler as its callback. The implementation in [`controller.py`](https://github.com/music-assistant/server/blob/main/controller.py) (lines 217-235) ensures only one browser instance runs regardless of how many providers require mDNS discovery.

### Phase 2: Handling Service State Changes

Each time Zeroconf detects a service state change—whether a device appears, updates, or disappears—the browser invokes `_on_mdns_service_state_change`. This method constructs an `AsyncServiceInfo` object and requests the full DNS record from the network.

```python
async def process_mdns_state_change(provider):
    lock = self._mdns_locks.setdefault(provider.instance_id, asyncio.Lock())
    if state_change == ServiceStateChange.Removed:
        info = None
    else:
        info = AsyncServiceInfo(service_type, name)
        await info.async_request(zeroconf, 3000)
    async with lock:
        await provider.on_mdns_service_state_change(name, state_change, info)

```

The controller maintains **per-provider locks** (`self._mdns_locks`) to prevent race conditions when multiple devices announce simultaneously. After resolving the service info, the controller forwards the data to every loaded provider that registered the corresponding service type. Providers implement `on_mdns_service_state_change` to instantiate player objects from the resolved IP addresses and TXT records.

### Phase 3: On-Demand Device Lookup

Some protocols require resolving a specific device by exact name after initial discovery. The `async_find_mdns_service` method (lines 153-198 in [`controller.py`](https://github.com/music-assistant/server/blob/main/controller.py)) supports this synchronous-style lookup pattern used by providers like AirPlay.

The method first scans the internal Zeroconf cache for a matching entry, handling MAC address prefixes (e.g., `aa:bb:cc:dd:ee:ff@DeviceName`) using the `RAOP_MAC_PREFIX` regex. If no match exists, it registers an `asyncio.Event` in `self._mdns_waiters` and waits for the next mDNS announcement, re-checking the cache until the timeout expires or a match appears.

## Implementation Details

### Browser Lifecycle Management

The controller manages the browser lifecycle through two private methods:

- **`_setup_mdns_browser()`**: Creates the `AsyncServiceBrowser` instance during `setup()`
- **`_cancel_mdns_browser()`**: Handles shutdown during `close()`, supporting both the modern `async_cancel()` coroutine and legacy `cancel()` method for compatibility

This centralized approach ensures network resources are properly released when the server shuts down.

### Replay for Late-Loading Providers

When a provider loads after the server has already discovered devices, the `_replay_mdns_discovery` method iterates over the Zeroconf cache and manually invokes `on_mdns_service_state_change` with `ServiceStateChange.Added` for each cached entry. This guarantees that late-loading providers see the same device inventory as those loaded at startup.

## Code Examples

### Provider Implementation: Reacting to mDNS Announcements

Providers subclassing the base provider model implement `on_mdns_service_state_change` to handle device arrivals. The Sonos provider demonstrates this pattern:

```python

# music_assistant/providers/sonos/provider.py

async def on_mdns_service_state_change(self, name, state_change, info):
    if state_change is ServiceStateChange.Added and info:
        ip = info.parsed_addresses()[0]
        port = info.port
        await self._handle_new_player(ip, port)

```

### On-Demand Lookup: Resolving Specific Devices

The AirPlay provider uses `async_find_mdns_service` to resolve RAOP services by exact device name:

```python

# music_assistant/providers/airplay/provider.py

async def add_player(self, device_name: str):
    raop_info = await self.mass.discovery.async_find_mdns_service(
        service_type="_raop._tcp.local.", 
        name_filter=device_name,
        timeout=10
    )
    if raop_info:
        ip = raop_info.parsed_addresses()[0]
        await self._setup_raop_player(ip, raop_info.port)

```

### Stand-Alone Discovery Script

To manually trigger mDNS discovery outside the standard provider lifecycle:

```python
import asyncio
from music_assistant import MusicAssistant

async def main():
    ma = MusicAssistant()
    await ma.start()
    info = await ma.discovery.async_find_mdns_service(
        service_type="_spotify-connect._tcp.local.", 
        name_filter="LivingRoom",
        timeout=5
    )
    if info:
        print(f"Found at {info.parsed_addresses()[0]}:{info.port}")

asyncio.run(main())

```

## Summary

- The DiscoveryController aggregates **mdns_discovery** service types from all provider manifests into a single set before creating the browser.
- A single **AsyncServiceBrowser** instance listens for all service types, forwarding events to providers via the **on_mdns_service_state_change** callback.
- **Per-provider asyncio Locks** prevent race conditions when processing concurrent mDNS announcements.
- The **async_find_mdns_service** method enables synchronous-style lookups by scanning the Zeroconf cache and waiting for announcements if necessary.
- The **replay mechanism** ensures late-loading providers receive discovery events for devices already present on the network.

## Frequently Asked Questions

### What is the role of AsyncServiceBrowser in the DiscoveryController?

The **AsyncServiceBrowser** is the Zeroconf component that listens for mDNS service announcements on the network. The DiscoveryController creates exactly one browser instance that monitors all service types declared across all provider manifests (such as `_raop._tcp.local.` or `_sonos._tcp.local.`), aggregating these into a single listener to optimize network resource usage rather than creating separate browsers for each provider.

### How does the DiscoveryController handle simultaneous mDNS events from multiple devices?

The controller maintains a dictionary of **asyncio.Lock** objects mapped to provider instance IDs (`self._mdns_locks`). When processing a state change in `process_mdns_state_change`, it acquires the lock specific to that provider before invoking `on_mdns_service_state_change`. This ensures that even if multiple devices announce simultaneously, each provider processes events sequentially without race conditions.

### Can providers request mDNS lookups on demand after initial discovery?

Yes. Providers call **async_find_mdns_service** with a specific service type and name filter to resolve a device by exact name. This method first checks the Zeroconf cache, handles MAC address prefixes using regex parsing, and if no match exists, waits for the next network announcement using an **asyncio.Event** mechanism. This is essential for protocols like AirPlay that need to resolve device connections after the initial browser discovery.

### What happens if a provider loads after devices have already been discovered?

The controller implements a **replay mechanism** through `_replay_mdns_discovery`. When a new provider loads, this method iterates over the existing Zeroconf cache and manually invokes `on_mdns_service_state_change` with `ServiceStateChange.Added` for each cached service type matching the provider's manifest. This ensures consistent device visibility regardless of provider initialization timing.