/
/
/
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(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(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 # Final check before registration to handle race conditions
178 # (multiple MDNS events processed in parallel for same device)
179 if self.mass.players.get_player(player_id):
180 self.logger.debug(
181 "Player %s already registered during setup, skipping registration", player_id
182 )
183 return
184
185 self.logger.debug(
186 "Setting up player %s: manufacturer=%s, model=%s",
187 display_name,
188 manufacturer,
189 model,
190 )
191
192 # Create single AirPlayPlayer for all devices
193 # Pairing config entries will be shown conditionally based on device type
194 player = AirPlayPlayer(
195 provider=self,
196 player_id=player_id,
197 raop_discovery_info=raop_discovery_info,
198 airplay_discovery_info=airplay_discovery_info,
199 address=address,
200 display_name=display_name,
201 manufacturer=manufacturer,
202 model=model,
203 initial_volume=volume,
204 )
205 await self.mass.players.register(player)
206
207 async def _handle_dacp_request( # noqa: PLR0915
208 self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
209 ) -> None:
210 """Handle new connection on the socket."""
211 try:
212 raw_request = b""
213 while recv := await reader.read(1024):
214 raw_request += recv
215 if len(recv) < 1024:
216 break
217 if not raw_request:
218 # Some device (Phorus PS10) seems to send empty request
219 # Maybe as a ack message? we have nothing to do here with empty request
220 # so we return early.
221 return
222
223 request = raw_request.decode("UTF-8")
224 if "\r\n\r\n" in request:
225 headers_raw, body = request.split("\r\n\r\n", 1)
226 else:
227 headers_raw = request
228 body = ""
229 headers_split = headers_raw.split("\r\n")
230 headers = {}
231 for line in headers_split[1:]:
232 if ":" not in line:
233 continue
234 x, y = line.split(":", 1)
235 headers[x.strip()] = y.strip()
236 active_remote = headers.get("Active-Remote")
237 _, path, _ = headers_split[0].split(" ")
238 # lookup airplay player by active remote id
239 player: AirPlayPlayer | None = next(
240 (
241 x
242 for x in self.get_players()
243 if x.stream and x.stream.active_remote_id == active_remote
244 ),
245 None,
246 )
247 self.logger.debug(
248 "DACP request for %s (%s): %s -- %s",
249 player.name if player else "UNKNOWN PLAYER",
250 active_remote,
251 path,
252 body,
253 )
254 if not player:
255 return
256
257 player_id = player.player_id
258 ignore_volume_report = (
259 self.mass.config.get_raw_player_config_value(player_id, CONF_IGNORE_VOLUME, False)
260 or player.device_info.manufacturer.lower() == "apple"
261 )
262 active_queue = self.mass.players.get_active_queue(player)
263 if not active_queue:
264 self.logger.warning(
265 "DACP request for %s (%s) but no active queue found, ignoring request",
266 player.display_name,
267 player_id,
268 )
269 return
270 if path == "/ctrl-int/1/nextitem":
271 self.mass.create_task(self.mass.player_queues.next(active_queue.queue_id))
272 elif path == "/ctrl-int/1/previtem":
273 self.mass.create_task(self.mass.player_queues.previous(active_queue.queue_id))
274 elif path == "/ctrl-int/1/play":
275 # sometimes this request is sent by a device as confirmation of a play command
276 # we ignore this if the player is already playing
277 if player.playback_state != PlaybackState.PLAYING:
278 self.mass.create_task(self.mass.player_queues.play(active_queue.queue_id))
279 elif path == "/ctrl-int/1/playpause":
280 self.mass.create_task(self.mass.player_queues.play_pause(active_queue.queue_id))
281 elif path == "/ctrl-int/1/stop":
282 self.mass.create_task(self.mass.player_queues.stop(active_queue.queue_id))
283 elif path == "/ctrl-int/1/volumeup":
284 self.mass.create_task(self.mass.players.cmd_volume_up(player_id))
285 elif path == "/ctrl-int/1/volumedown":
286 self.mass.create_task(self.mass.players.cmd_volume_down(player_id))
287 elif path == "/ctrl-int/1/shuffle_songs":
288 queue = self.mass.player_queues.get(player_id)
289 if not queue:
290 return
291 await self.mass.player_queues.set_shuffle(
292 active_queue.queue_id, not queue.shuffle_enabled
293 )
294 elif path in ("/ctrl-int/1/pause", "/ctrl-int/1/discrete-pause"):
295 # sometimes this request is sent by a device as confirmation of a play command
296 # we ignore this if the player is already playing
297 if player.playback_state == PlaybackState.PLAYING:
298 self.mass.create_task(self.mass.player_queues.pause(active_queue.queue_id))
299 elif "dmcp.device-volume=" in path and not ignore_volume_report:
300 # This is a bit annoying as this can be either the device confirming a new volume
301 # we've sent or the device requesting a new volume itself.
302 # In case of a small rounding difference, we ignore this,
303 # to prevent an endless pingpong of volume changes
304 airplay_volume = float(path.split("dmcp.device-volume=", 1)[-1])
305 volume = convert_airplay_volume(airplay_volume)
306 player.update_volume_from_device(volume)
307 elif "dmcp.volume=" in path:
308 # volume change request from device (e.g. volume buttons)
309 volume = int(path.split("dmcp.volume=", 1)[-1])
310 player.update_volume_from_device(volume)
311 elif "device-prevent-playback=1" in path:
312 # device switched to another source (or is powered off)
313 # Ignore during stream transition (stale message from old CLI process)
314 if player._transitioning:
315 self.logger.debug("Ignoring prevent-playback during stream transition")
316 elif stream := player.stream:
317 stream.prevent_playback = True
318 if stream.session:
319 self.mass.create_task(stream.session.remove_client(player))
320 elif "device-prevent-playback=0" in path:
321 # device reports that its ready for playback again
322 if stream := player.stream:
323 stream.prevent_playback = False
324
325 # send response
326 date_str = utc().strftime("%a, %-d %b %Y %H:%M:%S")
327 response = (
328 f"HTTP/1.0 204 No Content\r\nDate: {date_str} "
329 "GMT\r\nDAAP-Server: iTunes/7.6.2 (Windows; N;)\r\nContent-Type: "
330 "application/x-dmap-tagged\r\nContent-Length: 0\r\n"
331 "Connection: close\r\n\r\n"
332 )
333 writer.write(response.encode())
334 await writer.drain()
335 finally:
336 writer.close()
337
338 def get_players(self) -> list[AirPlayPlayer]:
339 """Return all airplay players belonging to this instance."""
340 return cast("list[AirPlayPlayer]", self.players)
341
342 def get_player(self, player_id: str) -> AirPlayPlayer | None:
343 """Return AirplayPlayer by id."""
344 return cast("AirPlayPlayer | None", self.mass.players.get_player(player_id))
345