/
/
/
1"""AirPlay Player provider for Music Assistant."""
2
3from __future__ import annotations
4
5import asyncio
6import socket
7from typing import cast
8
9from music_assistant_models.enums import PlaybackState
10from zeroconf import ServiceStateChange
11from zeroconf.asyncio import AsyncServiceInfo
12
13from music_assistant.helpers.datetime import utc
14from music_assistant.helpers.util import (
15 get_ip_pton,
16 get_primary_ip_address_from_zeroconf,
17 select_free_port,
18)
19from music_assistant.models.player_provider import PlayerProvider
20
21from .constants import (
22 AIRPLAY_DISCOVERY_TYPE,
23 CACHE_CATEGORY_PREV_VOLUME,
24 CONF_IGNORE_VOLUME,
25 DACP_DISCOVERY_TYPE,
26 FALLBACK_VOLUME,
27 RAOP_DISCOVERY_TYPE,
28)
29from .helpers import convert_airplay_volume, get_model_info
30from .player import AirPlayPlayer
31
32# TODO: AirPlay provider
33# - Implement authentication for Apple TV
34# - Implement volume control for Apple devices using pyatv
35# - Implement metadata for Apple Apple devices using pyatv
36# - Use pyatv for communicating with original Apple devices (and use cliraop for actual streaming)
37# - Implement AirPlay 2 support
38# - Implement late joining to existing stream (instead of restarting it)
39
40
41class AirPlayProvider(PlayerProvider):
42 """Player provider for AirPlay based players."""
43
44 _dacp_server: asyncio.Server
45 _dacp_info: AsyncServiceInfo
46
47 async def handle_async_init(self) -> None:
48 """Handle async initialization of the provider."""
49 # register DACP zeroconf service
50 dacp_port = await select_free_port(39831, 49831)
51 # Use first 16 hex chars of server_id as a persistent DACP ID
52 # This ensures the DACP ID remains the same across restarts, which is required
53 # for AirPlay 2 (HAP) pair-verify to work with previously paired devices
54 self.dacp_id = dacp_id = self.mass.server_id[:16].upper()
55 self.logger.debug("Starting DACP ActiveRemote %s on port %s", dacp_id, dacp_port)
56 self._dacp_server = await asyncio.start_server(self._handle_dacp_request, port=dacp_port)
57 server_id = f"iTunes_Ctrl_{dacp_id}.{DACP_DISCOVERY_TYPE}"
58 self._dacp_info = AsyncServiceInfo(
59 DACP_DISCOVERY_TYPE,
60 name=server_id,
61 addresses=[await get_ip_pton(str(self.mass.streams.publish_ip))],
62 port=dacp_port,
63 properties={
64 "txtvers": "1",
65 "Ver": "63B5E5C0C201542E",
66 "DbId": "63B5E5C0C201542E",
67 "OSsi": "0x1F5",
68 },
69 server=f"{socket.gethostname()}.local",
70 )
71 await self.mass.aiozc.async_register_service(self._dacp_info)
72
73 async def on_mdns_service_state_change(
74 self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
75 ) -> None:
76 """Handle MDNS service state callback."""
77 if not info:
78 if state_change == ServiceStateChange.Removed and "@" in name:
79 # Service name is enough to mark the player as unavailable on 'Removed' notification
80 raw_id, display_name = name.split(".")[0].split("@", 1)
81 else:
82 # If we are not in a 'Removed' state, we need info to be filled to update the player
83 return
84 elif "@" in info.name:
85 raw_id, display_name = info.name.split(".")[0].split("@", 1)
86 elif deviceid := info.decoded_properties.get("deviceid"):
87 raw_id = deviceid.replace(":", "")
88 display_name = info.name.split(".")[0]
89 else:
90 return
91 player_id = f"ap{raw_id.lower()}"
92 # handle removed player
93 if state_change == ServiceStateChange.Removed:
94 if _player := self.mass.players.get(player_id):
95 # the player has become unavailable
96 self.logger.debug("Player offline: %s", _player.display_name)
97 await self.mass.players.unregister(player_id)
98 return
99 # handle update for existing device
100 assert info is not None # type guard
101 player: AirPlayPlayer | None
102 if player := cast("AirPlayPlayer | None", self.mass.players.get(player_id)):
103 # update the latest discovery info for existing player
104 player.set_discovery_info(info, display_name)
105 return
106 await self._setup_player(player_id, display_name, info)
107
108 async def unload(self, is_removed: bool = False) -> None:
109 """Handle unload/close of the provider."""
110 # shutdown DACP server
111 if self._dacp_server:
112 self._dacp_server.close()
113 # shutdown DACP zeroconf service
114 if self._dacp_info:
115 await self.mass.aiozc.async_unregister_service(self._dacp_info)
116
117 async def _setup_player(
118 self, player_id: str, display_name: str, discovery_info: AsyncServiceInfo
119 ) -> None:
120 """Handle setup of a new player that is discovered using mdns."""
121 raop_discovery_info: AsyncServiceInfo | None = None
122 airplay_discovery_info: AsyncServiceInfo | None = None
123 if discovery_info.type == RAOP_DISCOVERY_TYPE:
124 # RAOP service discovered
125 raop_discovery_info = discovery_info
126 self.logger.debug("Discovered RAOP service for %s", display_name)
127 # always prefer airplay mdns info as it has more details
128 # fallback to raop info if airplay info is not available,
129 # (old device only announcing raop)
130 airplay_discovery_info = AsyncServiceInfo(
131 AIRPLAY_DISCOVERY_TYPE,
132 discovery_info.name.split("@")[-1].replace("_raop", "_airplay"),
133 )
134 await airplay_discovery_info.async_request(self.mass.aiozc.zeroconf, 3000)
135 else:
136 # AirPlay service discovered
137 self.logger.debug("Discovered AirPlay service for %s", display_name)
138 airplay_discovery_info = discovery_info
139
140 if airplay_discovery_info:
141 manufacturer, model = get_model_info(airplay_discovery_info)
142 elif raop_discovery_info:
143 manufacturer, model = get_model_info(raop_discovery_info)
144 else:
145 manufacturer, model = "Unknown", "Unknown"
146
147 address = get_primary_ip_address_from_zeroconf(discovery_info)
148 if not address:
149 return # should not happen, but guard just in case
150
151 # Filter out shairport-sync instances running on THIS Music Assistant server
152 # These are managed by the AirPlay Receiver provider, not the AirPlay provider
153 # We check both model name AND that it's a local address to avoid filtering
154 # shairport-sync instances running on other machines
155 if model == "ShairportSync":
156 # Check if this is a local address (127.x.x.x or matches our server's IP)
157 if address.startswith("127.") or address == self.mass.streams.publish_ip:
158 return
159
160 if not self.mass.config.get_raw_player_config_value(player_id, "enabled", True):
161 self.logger.debug("Ignoring %s in discovery as it is disabled.", display_name)
162 return
163 if not discovery_info:
164 return # should not happen, but guard just in case
165
166 # if we reach this point, all preflights are ok and we can create the player
167 self.logger.debug("Discovered AirPlay device %s on %s", display_name, address)
168
169 # Get volume from cache
170 if not (
171 volume := await self.mass.cache.get(
172 key=player_id, provider=self.instance_id, category=CACHE_CATEGORY_PREV_VOLUME
173 )
174 ):
175 volume = FALLBACK_VOLUME
176
177 # Append airplay to the default name for non-apple devices
178 # to make it easier for users to distinguish
179 is_apple = manufacturer.lower() == "apple"
180 if not is_apple and "airplay" not in display_name.lower():
181 display_name += " (AirPlay)"
182
183 # Final check before registration to handle race conditions
184 # (multiple MDNS events processed in parallel for same device)
185 if self.mass.players.get(player_id):
186 self.logger.debug(
187 "Player %s already registered during setup, skipping registration", player_id
188 )
189 return
190
191 self.logger.debug(
192 "Setting up player %s: manufacturer=%s, model=%s",
193 display_name,
194 manufacturer,
195 model,
196 )
197
198 # Create single AirPlayPlayer for all devices
199 # Pairing config entries will be shown conditionally based on device type
200 player = AirPlayPlayer(
201 provider=self,
202 player_id=player_id,
203 raop_discovery_info=raop_discovery_info,
204 airplay_discovery_info=airplay_discovery_info,
205 address=address,
206 display_name=display_name,
207 manufacturer=manufacturer,
208 model=model,
209 initial_volume=volume,
210 )
211 await self.mass.players.register(player)
212
213 async def _handle_dacp_request( # noqa: PLR0915
214 self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
215 ) -> None:
216 """Handle new connection on the socket."""
217 try:
218 raw_request = b""
219 while recv := await reader.read(1024):
220 raw_request += recv
221 if len(recv) < 1024:
222 break
223 if not raw_request:
224 # Some device (Phorus PS10) seems to send empty request
225 # Maybe as a ack message? we have nothing to do here with empty request
226 # so we return early.
227 return
228
229 request = raw_request.decode("UTF-8")
230 if "\r\n\r\n" in request:
231 headers_raw, body = request.split("\r\n\r\n", 1)
232 else:
233 headers_raw = request
234 body = ""
235 headers_split = headers_raw.split("\r\n")
236 headers = {}
237 for line in headers_split[1:]:
238 if ":" not in line:
239 continue
240 x, y = line.split(":", 1)
241 headers[x.strip()] = y.strip()
242 active_remote = headers.get("Active-Remote")
243 _, path, _ = headers_split[0].split(" ")
244 # lookup airplay player by active remote id
245 player: AirPlayPlayer | None = next(
246 (
247 x
248 for x in self.get_players()
249 if x.stream and x.stream.active_remote_id == active_remote
250 ),
251 None,
252 )
253 self.logger.debug(
254 "DACP request for %s (%s): %s -- %s",
255 player.name if player else "UNKNOWN PLAYER",
256 active_remote,
257 path,
258 body,
259 )
260 if not player:
261 return
262
263 player_id = player.player_id
264 ignore_volume_report = (
265 self.mass.config.get_raw_player_config_value(player_id, CONF_IGNORE_VOLUME, False)
266 or player.device_info.manufacturer.lower() == "apple"
267 )
268 active_queue = self.mass.player_queues.get_active_queue(player_id)
269 if not active_queue:
270 self.logger.warning(
271 "DACP request for %s (%s) but no active queue found, ignoring request",
272 player.display_name,
273 player_id,
274 )
275 return
276 if path == "/ctrl-int/1/nextitem":
277 self.mass.create_task(self.mass.player_queues.next(active_queue.queue_id))
278 elif path == "/ctrl-int/1/previtem":
279 self.mass.create_task(self.mass.player_queues.previous(active_queue.queue_id))
280 elif path == "/ctrl-int/1/play":
281 # sometimes this request is sent by a device as confirmation of a play command
282 # we ignore this if the player is already playing
283 if player.playback_state != PlaybackState.PLAYING:
284 self.mass.create_task(self.mass.player_queues.play(active_queue.queue_id))
285 elif path == "/ctrl-int/1/playpause":
286 self.mass.create_task(self.mass.player_queues.play_pause(active_queue.queue_id))
287 elif path == "/ctrl-int/1/stop":
288 self.mass.create_task(self.mass.player_queues.stop(active_queue.queue_id))
289 elif path == "/ctrl-int/1/volumeup":
290 self.mass.create_task(self.mass.players.cmd_volume_up(player_id))
291 elif path == "/ctrl-int/1/volumedown":
292 self.mass.create_task(self.mass.players.cmd_volume_down(player_id))
293 elif path == "/ctrl-int/1/shuffle_songs":
294 queue = self.mass.player_queues.get(player_id)
295 if not queue:
296 return
297 await self.mass.player_queues.set_shuffle(
298 active_queue.queue_id, not queue.shuffle_enabled
299 )
300 elif path in ("/ctrl-int/1/pause", "/ctrl-int/1/discrete-pause"):
301 # sometimes this request is sent by a device as confirmation of a play command
302 # we ignore this if the player is already playing
303 if player.playback_state == PlaybackState.PLAYING:
304 self.mass.create_task(self.mass.player_queues.pause(active_queue.queue_id))
305 elif "dmcp.device-volume=" in path and not ignore_volume_report:
306 # This is a bit annoying as this can be either the device confirming a new volume
307 # we've sent or the device requesting a new volume itself.
308 # In case of a small rounding difference, we ignore this,
309 # to prevent an endless pingpong of volume changes
310 airplay_volume = float(path.split("dmcp.device-volume=", 1)[-1])
311 volume = convert_airplay_volume(airplay_volume)
312 player.update_volume_from_device(volume)
313 elif "dmcp.volume=" in path:
314 # volume change request from device (e.g. volume buttons)
315 volume = int(path.split("dmcp.volume=", 1)[-1])
316 player.update_volume_from_device(volume)
317 elif "device-prevent-playback=1" in path:
318 # device switched to another source (or is powered off)
319 # Ignore during stream transition (stale message from old CLI process)
320 if player._transitioning:
321 self.logger.debug("Ignoring prevent-playback during stream transition")
322 elif stream := player.stream:
323 stream.prevent_playback = True
324 if stream.session:
325 self.mass.create_task(stream.session.remove_client(player))
326 elif "device-prevent-playback=0" in path:
327 # device reports that its ready for playback again
328 if stream := player.stream:
329 stream.prevent_playback = False
330
331 # send response
332 date_str = utc().strftime("%a, %-d %b %Y %H:%M:%S")
333 response = (
334 f"HTTP/1.0 204 No Content\r\nDate: {date_str} "
335 "GMT\r\nDAAP-Server: iTunes/7.6.2 (Windows; N;)\r\nContent-Type: "
336 "application/x-dmap-tagged\r\nContent-Length: 0\r\n"
337 "Connection: close\r\n\r\n"
338 )
339 writer.write(response.encode())
340 await writer.drain()
341 finally:
342 writer.close()
343
344 def get_players(self) -> list[AirPlayPlayer]:
345 """Return all airplay players belonging to this instance."""
346 return cast("list[AirPlayPlayer]", self.players)
347
348 def get_player(self, player_id: str) -> AirPlayPlayer | None:
349 """Return AirplayPlayer by id."""
350 return cast("AirPlayPlayer | None", self.mass.players.get(player_id))
351