/
/
/
1"""AirPlay Player provider for Music Assistant."""
2
3from __future__ import annotations
4
5import asyncio
6import base64
7import json
8import logging
9import os
10import socket
11import time
12from contextlib import suppress
13from ipaddress import IPv4Address, IPv6Address, ip_address
14from typing import TYPE_CHECKING, Final, cast
15
16from music_assistant_models.config_entries import ConfigEntry
17from music_assistant_models.enums import ConfigEntryType, PlaybackState
18from music_assistant_models.errors import MediaNotFoundError
19from zeroconf import NonUniqueNameException, ServiceStateChange
20from zeroconf.asyncio import AsyncServiceInfo
21
22from music_assistant.constants import (
23 CONF_LOG_LEVEL,
24 CONF_PLAYERS,
25 CONF_PROVIDERS,
26 VERBOSE_LOG_LEVEL,
27)
28from music_assistant.helpers.config_entries import (
29 CONF_CONNECTED_PLAYERS,
30 CONF_PUBLISH_NAME_TEMPLATE,
31 resolve_publish_name,
32)
33from music_assistant.helpers.datetime import utc
34from music_assistant.helpers.json import SerializableType
35from music_assistant.helpers.process import AsyncProcess
36from music_assistant.helpers.util import (
37 get_ip_addresses,
38 get_ip_pton,
39 get_primary_ip_address_from_zeroconf,
40 select_free_port,
41)
42from music_assistant.models.player_provider import PlayerProvider
43
44from .constants import (
45 AIRPLAY_DISCOVERY_TYPE,
46 AIRPLAY_VOLUME_MUTE,
47 CLI_PROBLEM_MARKERS,
48 COMPANION_DISCOVERY_TYPE,
49 CONF_COMPAT_PINS_REVIEWED,
50 CONF_PASSWORD_INVALID,
51 CONF_PASSWORD_MARKERS_REVIEWED,
52 CONF_STORED_VOLUME,
53 CONF_STREAMING_MODE,
54 CONF_VERBOSE_PTP_LOGGING,
55 DACP_DISCOVERY_TYPE,
56 EXTERNAL_ARTWORK_PATH_PREFIX,
57 FALLBACK_VOLUME,
58 MRP_DISCOVERY_TYPE,
59 PTP_DAEMON_WARN_BURST,
60 PTP_DAEMON_WARN_WINDOW,
61 RAOP_DISCOVERY_TYPE,
62 STREAMING_MODE_AP2_COMPAT,
63 STREAMING_MODE_AUTO,
64 AirPlayRemoteCommand,
65 StreamingProtocol,
66)
67from .control_player import AirPlayControlPlayer
68from .dashboard import AirPlayDashboards
69from .helpers import (
70 convert_airplay_volume,
71 get_cli_binary,
72 get_model_info,
73 is_apple_device,
74 probe_audio_formats,
75)
76from .player import AirPlayPlayer, GenericAirPlayPlayer
77from .sendspin_bridge import SendspinBridgeManager
78
79if TYPE_CHECKING:
80 from music_assistant_models.config_entries import ProviderConfig
81
82# Marker the `cliairplay --ptp-daemon` process prints once it has bound the
83# privileged PTP ports (UDP 319/320) and opened its control channel. Until this
84# line is seen the daemon is spawned but not yet able to serve shared-clock
85# streams, so a group start before it would race the daemon's readiness.
86PTP_DAEMON_READY_MARKER: Final[str] = "[PTP] daemon up"
87# Bounded wait for the daemon to report readiness before a stream session
88# decides its timing source. Once ready the check returns immediately; only a
89# session that starts while the daemon is still coming up (or never binds) pays
90# any of this, and only up to the moment readiness is signalled.
91PTP_DAEMON_READY_TIMEOUT: Final[float] = 3.0
92# Grace period after broadcasting a goodbye for a stale DACP registration before
93# re-registering the (name-stable) service, letting the cache flush the old record.
94DACP_RECLAIM_DELAY: Final[float] = 1.0
95# Opt-in for pyatv's own debug logging. Set to any non-empty value to trace the
96# pyatv protocol traffic itself.
97ENV_PYATV_DEBUG: Final[str] = "MASS_PYATV_DEBUG"
98
99
100class AirPlayProvider(PlayerProvider):
101 """Player provider for AirPlay based players."""
102
103 reload_on_streams_network_change = True
104 _dacp_server: asyncio.Server
105 _dacp_info: AsyncServiceInfo
106 _bridge_manager: SendspinBridgeManager
107 dashboards: AirPlayDashboards
108 _ptp_daemon: AsyncProcess | None = None
109 _ptp_daemon_stdout_task: asyncio.Task[None] | None = None
110 _ptp_daemon_started: float = 0.0
111 _ptp_daemon_restarted: bool = False
112 _ptp_daemon_stop_requested: bool = False
113 # Set once the running daemon reports it has bound 319/320 and opened its
114 # control channel; created/cleared per daemon start so a crash+restart
115 # re-gates readiness. None until the daemon is first started.
116 _ptp_daemon_ready: asyncio.Event | None = None
117 # Rate-limit state for daemon lines promoted to WARNING (see
118 # PTP_DAEMON_WARN_BURST).
119 _ptp_daemon_warn_window_start: float | None = None
120 _ptp_daemon_warns_in_window: int = 0
121 _ptp_daemon_warns_suppressed: int = 0
122
123 @property
124 def bridge_manager(self) -> SendspinBridgeManager:
125 """Return the Sendspin bridge manager."""
126 return self._bridge_manager
127
128 @property
129 def ptp_daemon_running(self) -> bool:
130 """
131 Return if the shared PTP clock daemon process is alive.
132
133 This reflects process liveness only (spawned, not closed, still
134 running). It says nothing about whether streams can use it: a daemon
135 that never bound its ports stays alive and reports True here while every
136 group silently degrades to NTP. Use :attr:`ptp_daemon_ready` for the
137 real state, or :meth:`wait_ptp_daemon_ready` to wait for it.
138 """
139 return (
140 self._ptp_daemon is not None
141 and not self._ptp_daemon.closed
142 and self._ptp_daemon.returncode is None
143 )
144
145 @property
146 def ptp_daemon_ready(self) -> bool:
147 """
148 Return whether the shared PTP clock daemon is serving streams right now.
149
150 This is the condition a session actually gates on: the daemon has bound
151 the privileged PTP ports (UDP 319/320), opened its control channel, and
152 is still alive.
153 """
154 return (
155 self.ptp_daemon_running
156 and self._ptp_daemon_ready is not None
157 and self._ptp_daemon_ready.is_set()
158 )
159
160 async def wait_ptp_daemon_ready(self, timeout: float = PTP_DAEMON_READY_TIMEOUT) -> bool:
161 """
162 Wait until the shared PTP clock daemon is ready to serve streams.
163
164 Readiness means the daemon has bound the privileged PTP ports (UDP
165 319/320) and opened its control channel - not merely that the process is
166 alive. A stream session gates its group-wide timing decision on this so
167 it never attaches members to a clock that is not yet serving.
168
169 :param timeout: Maximum seconds to wait for the readiness signal.
170 :return: True if the daemon has signalled readiness, False otherwise
171 (never started, failed to bind, or not ready within the timeout).
172 """
173 event = self._ptp_daemon_ready
174 daemon = self._ptp_daemon
175 if event is None or daemon is None or daemon.closed or daemon.returncode is not None:
176 return False
177 if event.is_set():
178 return True
179
180 ready_task = asyncio.create_task(event.wait())
181 exit_task = asyncio.create_task(daemon.wait())
182 try:
183 done, _ = await asyncio.wait(
184 (ready_task, exit_task),
185 timeout=timeout,
186 return_when=asyncio.FIRST_COMPLETED,
187 )
188 return (
189 ready_task in done
190 and event.is_set()
191 and self._ptp_daemon is daemon
192 and not daemon.closed
193 and daemon.returncode is None
194 )
195 finally:
196 ready_task.cancel()
197 exit_task.cancel()
198 await asyncio.gather(ready_task, exit_task, return_exceptions=True)
199
200 def handle_remote_command(self, player: AirPlayPlayer, command: AirPlayRemoteCommand) -> None:
201 """Dispatch a transport command received from an AirPlay receiver."""
202 player_id = (
203 self.bridge_manager.get_transport_command_target(player.player_id) or player.player_id
204 )
205 match command:
206 case AirPlayRemoteCommand.PLAY:
207 # Some receivers echo play as confirmation of a command from MA.
208 if player.playback_state != PlaybackState.PLAYING:
209 self.mass.create_task(self.mass.players.cmd_play(player_id))
210 case AirPlayRemoteCommand.PAUSE:
211 if player.playback_state == PlaybackState.PLAYING:
212 self.mass.create_task(self.mass.players.cmd_pause(player_id))
213 case AirPlayRemoteCommand.PLAY_PAUSE:
214 self.mass.create_task(self.mass.players.cmd_play_pause(player_id))
215 case AirPlayRemoteCommand.NEXT:
216 self.mass.create_task(self.mass.players.cmd_next_track(player_id))
217 case AirPlayRemoteCommand.PREVIOUS:
218 self.mass.create_task(self.mass.players.cmd_previous_track(player_id))
219
220 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
221 """Return Config entries to configure this provider."""
222 return (
223 ConfigEntry(
224 key=CONF_VERBOSE_PTP_LOGGING,
225 type=ConfigEntryType.BOOLEAN,
226 default_value=False,
227 required=False,
228 advanced=True,
229 ),
230 )
231
232 async def handle_async_init(self) -> None:
233 """Handle async initialization of the provider."""
234 self._set_pyatv_log_level()
235 self._drop_unverified_password_markers()
236 self._reset_auto_pinned_compat_modes()
237 self._companion_info_by_address: dict[str, AsyncServiceInfo] = {}
238 self._mrp_info_by_address: dict[str, AsyncServiceInfo] = {}
239 # Shared audible instants for in-flight announcements, keyed by
240 # (group-or-leader player id, render key) -> unix ms. The controller
241 # forwards a group-entity announcement to every member concurrently;
242 # each call arms its own member and reuses the instant the first call
243 # planned, so all rooms render the clip in sync (managed by
244 # announce.py, pruned as plans pass).
245 self._announce_plans: dict[tuple[str, str], int] = {}
246
247 # Initialize Sendspin bridge manager for protocol linking
248 self._bridge_manager = SendspinBridgeManager(self)
249
250 # Registers eligible Apple TVs as dashboard endpoints for the tvOS app
251 self.dashboards = AirPlayDashboards(self)
252
253 # register DACP zeroconf service
254 dacp_port = await select_free_port(39831, 49831)
255 # Use first 16 hex chars of server_id as a persistent DACP ID
256 # This ensures the DACP ID remains the same across restarts, which is required
257 # for AirPlay 2 (HAP) pair-verify to work with previously paired devices
258 self.dacp_id = dacp_id = self.mass.server_id[:16].upper()
259 self.logger.debug("Starting DACP ActiveRemote %s on port %s", dacp_id, dacp_port)
260 self._dacp_server = await asyncio.start_server(self._handle_dacp_request, port=dacp_port)
261 server_id = f"iTunes_Ctrl_{dacp_id}.{DACP_DISCOVERY_TYPE}"
262 self._dacp_info = AsyncServiceInfo(
263 DACP_DISCOVERY_TYPE,
264 name=server_id,
265 addresses=[await get_ip_pton(self.mass.streams.publish_ip)],
266 port=dacp_port,
267 properties={
268 "txtvers": "1",
269 "Ver": "63B5E5C0C201542E",
270 "DbId": "63B5E5C0C201542E",
271 "OSsi": "0x1F5",
272 },
273 server=f"{socket.gethostname()}.local",
274 )
275 await self._register_dacp_service()
276
277 # Run one shared PTP clock daemon for the provider lifetime: all native
278 # AirPlay 2 streams attach to it (--ptp-shared) so multi-room sync groups
279 # lock to a single grandmaster while UDP 319/320 is bound only once.
280 await self._start_ptp_daemon()
281
282 async def update_config(self, config: ProviderConfig, changed_keys: set[str]) -> None:
283 """Handle logic when the config is updated."""
284 await super().update_config(config, changed_keys)
285 # a log level(-only) change does not reload the provider,
286 # so realign pyatv's logger here
287 if f"values/{CONF_LOG_LEVEL}" in changed_keys:
288 self._set_pyatv_log_level()
289
290 async def on_mdns_service_state_change(
291 self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
292 ) -> None:
293 """Handle MDNS service state callback."""
294 if (info and info.type == COMPANION_DISCOVERY_TYPE) or name.endswith(
295 COMPANION_DISCOVERY_TYPE
296 ):
297 await self._handle_companion_service_state_change(name, state_change, info)
298 return
299 if (info and info.type == MRP_DISCOVERY_TYPE) or name.endswith(MRP_DISCOVERY_TYPE):
300 await self._handle_mrp_service_state_change(name, state_change, info)
301 return
302 if not info:
303 if state_change == ServiceStateChange.Removed and "@" in name:
304 # Service name is enough to mark the player as unavailable on 'Removed' notification
305 raw_id, display_name = name.split(".", maxsplit=1)[0].split("@", 1)
306 else:
307 # If we are not in a 'Removed' state, we need info to be filled to update the player
308 return
309 elif "@" in info.name:
310 raw_id, display_name = info.name.split(".")[0].split("@", 1)
311 elif deviceid := info.decoded_properties.get("deviceid"):
312 raw_id = deviceid.replace(":", "")
313 display_name = info.name.split(".")[0]
314 else:
315 return
316 player_id = f"ap{raw_id.lower()}"
317 # handle removed player
318 if state_change == ServiceStateChange.Removed:
319 if _player := self.mass.players.get_player(player_id):
320 # the player has become unavailable
321 self.logger.debug("Player offline: %s", _player.display_name)
322 # Remove the Sendspin bridge first
323 await self._bridge_manager.remove_bridge(player_id)
324 await self.mass.players.unregister(player_id)
325 self.dashboards.unregister(player_id)
326 return
327 # handle update for existing device
328 assert info is not None # type guard
329 player: AirPlayPlayer | None
330 if player := cast("AirPlayPlayer | None", self.mass.players.get_player(player_id)):
331 # update the latest discovery info for existing player
332 player.set_discovery_info(info, display_name)
333 # only control players can ever be dashboard endpoints
334 if isinstance(player, AirPlayControlPlayer):
335 self.dashboards.reconcile(player_id)
336 return
337 await self._setup_player(player_id, display_name, info)
338
339 async def unload(self, is_removed: bool = False) -> None:
340 """Handle unload/close of the provider."""
341 # Unregister all dashboard endpoints
342 dashboards = getattr(self, "dashboards", None)
343 if dashboards:
344 await dashboards.unload()
345 # Stop all Sendspin bridges
346 bridge_manager = getattr(self, "_bridge_manager", None)
347 if bridge_manager:
348 await bridge_manager.close()
349 # terminate the shared PTP clock daemon
350 self._ptp_daemon_stop_requested = True
351 if self._ptp_daemon_ready is not None:
352 self._ptp_daemon_ready.clear()
353 ptp_stdout_task = self._ptp_daemon_stdout_task
354 if ptp_stdout_task and not ptp_stdout_task.done():
355 ptp_stdout_task.cancel()
356 with suppress(asyncio.CancelledError):
357 await ptp_stdout_task
358 if self._ptp_daemon and not self._ptp_daemon.closed:
359 await self._ptp_daemon.close()
360 self._ptp_daemon = None
361 # shutdown DACP server
362 if self._dacp_server:
363 self._dacp_server.close()
364 # shutdown DACP zeroconf service
365 if self._dacp_info:
366 await self.mass.discovery.aiozc.async_unregister_service(self._dacp_info)
367
368 async def get_diagnostics(self) -> dict[str, SerializableType]:
369 """Return diagnostics info for this provider to include in diagnostics reports."""
370 streams_by_type: dict[str, int] = {}
371 streams_by_route: dict[str, int] = {}
372 for player in self.get_players():
373 if not (player.stream and player.stream.running):
374 continue
375 stream_type = "airplay2" if player.protocol == StreamingProtocol.AIRPLAY2 else "raop"
376 streams_by_type[stream_type] = streams_by_type.get(stream_type, 0) + 1
377 # The route the binary resolved names the timing source each stream
378 # really got (PTP or NTP), which the daemon flags cannot: a daemon
379 # can be alive and every stream still be running on NTP. A process
380 # that has not reported its route yet is counted apart rather than
381 # folded into either.
382 route = player.stream.active_route or "unreported"
383 streams_by_route[route] = streams_by_route.get(route, 0) + 1
384 return {
385 "dacp_server_running": self._dacp_server.is_serving(),
386 "ptp_daemon_running": self.ptp_daemon_running,
387 "ptp_daemon_ready": self.ptp_daemon_ready,
388 "active_streams": sum(streams_by_type.values()),
389 "streams_by_type": streams_by_type,
390 "streams_by_route": streams_by_route,
391 }
392
393 def get_players(self) -> list[AirPlayPlayer]:
394 """Return all airplay players belonging to this instance."""
395 return cast("list[AirPlayPlayer]", self.players)
396
397 def get_player(self, player_id: str) -> AirPlayPlayer | None:
398 """Return AirplayPlayer by id."""
399 return cast("AirPlayPlayer | None", self.mass.players.get_player(player_id))
400
401 async def resolve_image(self, path: str) -> bytes:
402 """
403 Resolve artwork for the current external media on an Apple device.
404
405 :param path: AirPlay artwork path produced for the image proxy.
406 :return: Raw artwork bytes.
407 :raises MediaNotFoundError: If the artwork is invalid, stale, or unavailable.
408 """
409 try:
410 prefix, player_id, artwork_id = path.split("/", 2)
411 except ValueError as err:
412 raise MediaNotFoundError("Invalid AirPlay artwork path") from err
413 player = self.get_player(player_id)
414 if (
415 prefix != EXTERNAL_ARTWORK_PATH_PREFIX
416 or not artwork_id
417 or not isinstance(player, AirPlayControlPlayer)
418 ):
419 raise MediaNotFoundError("AirPlay artwork is unavailable")
420 return await player.async_get_external_artwork(artwork_id)
421
422 def _set_pyatv_log_level(self) -> None:
423 """Keep pyatv's (very chatty) logging quiet unless it is explicitly asked for."""
424 # pyatv logs every protocol message, HTTP exchange and encrypted payload of
425 # each control connection at debug level, which buries our own logging and
426 # rotates the log file within minutes. Its debug output is therefore held
427 # back even on verbose sessions, which are meant to surface our own deep
428 # diagnostics (the cliairplay [STATUS] and PTP traces) rather than pyatv's.
429 if os.environ.get(ENV_PYATV_DEBUG):
430 logging.getLogger("pyatv").setLevel(logging.DEBUG)
431 else:
432 logging.getLogger("pyatv").setLevel(max(self.logger.level + 10, logging.INFO))
433
434 async def _setup_player(
435 self, player_id: str, display_name: str, discovery_info: AsyncServiceInfo
436 ) -> None:
437 """Handle setup of a new player that is discovered using mdns."""
438 # return early if player is disabled in config
439 if not self.mass.config.get_raw_player_config_value(player_id, "enabled", True):
440 self.logger.debug("Ignoring %s in discovery as it is disabled.", display_name)
441 return
442 # Filter out this server's own AirPlay Receiver (shairport-sync) instances
443 # before anything else: they must never register as AirPlay players.
444 if await self._is_own_airplay_receiver(display_name, discovery_info):
445 self.logger.debug(
446 "Ignoring %s in discovery: it is an AirPlay Receiver instance of this server",
447 display_name,
448 )
449 return
450 raop_discovery_info: AsyncServiceInfo | None = None
451 airplay_discovery_info: AsyncServiceInfo | None = None
452 if discovery_info.type == RAOP_DISCOVERY_TYPE:
453 # RAOP service discovered - try to also find the AirPlay service
454 raop_discovery_info = discovery_info
455 self.logger.debug("Discovered RAOP service for %s", display_name)
456 airplay_discovery_info = await self.mass.discovery.async_find_mdns_service(
457 AIRPLAY_DISCOVERY_TYPE, display_name, timeout=10.0
458 )
459 else:
460 # AirPlay service discovered - try to also find the RAOP service
461 self.logger.debug("Discovered AirPlay service for %s", display_name)
462 airplay_discovery_info = discovery_info
463 raop_discovery_info = await self.mass.discovery.async_find_mdns_service(
464 RAOP_DISCOVERY_TYPE, display_name, timeout=10.0
465 )
466
467 if airplay_discovery_info:
468 model_discovery_info = airplay_discovery_info
469 elif raop_discovery_info:
470 model_discovery_info = raop_discovery_info
471 else:
472 return # should not happen, but guard just in case
473 manufacturer, model = get_model_info(model_discovery_info)
474
475 prefer_ipv6 = ":" in str(self.mass.streams.publish_ip)
476 address = get_primary_ip_address_from_zeroconf(discovery_info, prefer_ipv6=prefer_ipv6)
477 if not address:
478 return # should not happen, but guard just in case
479
480 # if we reach this point, all preflights are ok and we can create the player
481 self.logger.debug("Discovered AirPlay device %s on %s", display_name, address)
482
483 # Get stored volume from playerconfig
484 volume = int(
485 self.mass.config.get_raw_player_config_value(
486 player_id, CONF_STORED_VOLUME, FALLBACK_VOLUME
487 )
488 )
489
490 # Final check before registration to handle race conditions
491 # (multiple MDNS events processed in parallel for same device)
492 if self.mass.players.get_player(player_id):
493 self.logger.debug(
494 "Player %s already registered during setup, skipping registration", player_id
495 )
496 return
497
498 self.logger.debug(
499 "Setting up player %s: manufacturer=%s, model=%s",
500 display_name,
501 manufacturer,
502 model,
503 )
504
505 player_addresses = self._get_discovery_addresses(
506 airplay_discovery_info,
507 raop_discovery_info,
508 )
509 player_addresses.add(address)
510 # Apple TVs and HomePods are standalone players with a native control
511 # plane (Companion/MRP); all other receivers are protocol endpoints.
512 # The model is decided from the device's own identity only: it defines
513 # the player id exposed to API consumers (e.g. Home Assistant), so it
514 # must not vary with the discovery timing of the separate Companion/MRP
515 # mDNS records. Which control features are offered on a control player
516 # is decided from the advertised capabilities instead.
517 enhanced_control = is_apple_device(manufacturer, model)
518 companion_info: AsyncServiceInfo | None = None
519 mrp_info: AsyncServiceInfo | None = None
520 if enhanced_control:
521 companion_info, mrp_info = await asyncio.gather(
522 self._get_related_discovery_info(
523 COMPANION_DISCOVERY_TYPE,
524 self._companion_info_by_address,
525 player_addresses,
526 display_name,
527 ),
528 self._get_related_discovery_info(
529 MRP_DISCOVERY_TYPE,
530 self._mrp_info_by_address,
531 player_addresses,
532 display_name,
533 ),
534 )
535
536 player: AirPlayPlayer
537 if enhanced_control:
538 player = AirPlayControlPlayer(
539 provider=self,
540 player_id=player_id,
541 raop_discovery_info=raop_discovery_info,
542 airplay_discovery_info=airplay_discovery_info,
543 companion_discovery_info=companion_info,
544 mrp_discovery_info=mrp_info,
545 address=address,
546 display_name=display_name,
547 manufacturer=manufacturer,
548 model=model,
549 initial_volume=volume,
550 )
551 else:
552 player = GenericAirPlayPlayer(
553 provider=self,
554 player_id=player_id,
555 raop_discovery_info=raop_discovery_info,
556 airplay_discovery_info=airplay_discovery_info,
557 address=address,
558 display_name=display_name,
559 manufacturer=manufacturer,
560 model=model,
561 initial_volume=volume,
562 )
563 await self.mass.players.register(player)
564
565 # A receiver only publishes its audio formats (and so whether it can do
566 # 24-bit) in its /info response, never in its mDNS records, so ask it
567 # directly. Off the discovery path: mdns callbacks are serialized per
568 # provider, so an unreachable device must not hold up the next player.
569 if airplay_discovery_info and airplay_discovery_info.port:
570 self.mass.create_task(
571 self._learn_audio_formats(player, address, airplay_discovery_info.port)
572 )
573
574 # Set up Sendspin bridge for protocol linking (if Sendspin provider is available)
575 await self._bridge_manager.evaluate_bridge(player)
576
577 # Track control players (Apple TVs) for dashboard eligibility
578 if isinstance(player, AirPlayControlPlayer):
579 self.dashboards.setup_player(player)
580
581 async def _learn_audio_formats(self, player: AirPlayPlayer, host: str, port: int) -> None:
582 """Read the audio formats a receiver advertises, so 24-bit can be auto-enabled."""
583 if formats := await probe_audio_formats(self.mass, host, port):
584 player.advertised_audio_formats = formats
585
586 async def _is_own_airplay_receiver(
587 self, display_name: str, discovery_info: AsyncServiceInfo
588 ) -> bool:
589 """
590 Return whether a discovered service is an AirPlay Receiver instance of this server.
591
592 :param display_name: The advertised device name (mdns name without any id prefix).
593 :param discovery_info: The mdns service info that triggered the discovery.
594 """
595 from music_assistant.providers.airplay_receiver import ( # noqa: PLC0415
596 airplay_receiver_ports,
597 )
598
599 # Collect the advertised names and ports of all configured AirPlay Receiver
600 # instances from raw config storage: this works regardless of provider load
601 # order at boot, when the receiver instances may not be running yet.
602 receiver_names: set[str] = set()
603 receiver_ports: set[int] = set()
604 for instance_id, raw_conf in self.mass.config.get(CONF_PROVIDERS, {}).items():
605 if not isinstance(raw_conf, dict) or raw_conf.get("domain") != "airplay_receiver":
606 continue
607 if not raw_conf.get("enabled", True):
608 continue
609 values = raw_conf.get("values")
610 values = values if isinstance(values, dict) else {}
611 player_ids = [str(player_id) for player_id in values.get(CONF_CONNECTED_PLAYERS) or []]
612 receiver_ports.update(airplay_receiver_ports(str(instance_id), player_ids).values())
613 template = values.get(CONF_PUBLISH_NAME_TEMPLATE)
614 for player_id in player_ids:
615 # best effort: a registered player's live name, else its stored config
616 # name; when neither resolves the port match stays the strong signal
617 if player := self.mass.players.get_player(player_id):
618 player_name: str | None = player.display_name
619 else:
620 stored_name = self.mass.config.get_raw_player_config_value(
621 player_id, "name"
622 ) or self.mass.config.get_raw_player_config_value(player_id, "default_name")
623 player_name = str(stored_name) if stored_name else None
624 if player_name:
625 receiver_names.add(resolve_publish_name(template, player_name))
626 # running instances are authoritative for the actual daemon ports
627 for prov in self.mass.get_provider_instances("airplay_receiver"):
628 if ports := getattr(prov, "airplay_ports", None):
629 receiver_ports.update(ports)
630 if not receiver_names and not receiver_ports:
631 return False
632
633 # The advertisement must originate from this host. shairport-sync's embedded
634 # mDNS responder announces address records for every host interface and which
635 # one resolves (first) is undefined, so match the full advertised address set
636 # against all of this host's addresses instead of a single picked address.
637 advertised_ips: set[IPv4Address | IPv6Address] = set()
638 for value in discovery_info.parsed_addresses():
639 with suppress(ValueError):
640 advertised_ips.add(ip_address(value))
641 if not advertised_ips:
642 return False
643 if not any(advertised_ip.is_loopback for advertised_ip in advertised_ips):
644 host_ips: set[IPv4Address | IPv6Address] = set()
645 for value in await get_ip_addresses(include_ipv6=True):
646 with suppress(ValueError):
647 host_ips.add(ip_address(value))
648 if advertised_ips.isdisjoint(host_ips):
649 return False
650
651 # Match by advertised name (robust even when the TXT record did not resolve).
652 if display_name in receiver_names:
653 return True
654 # Match by port, gated on the shairport-sync model so user-run AirPlay
655 # receivers on this machine (e.g. the macOS built-in receiver, which also
656 # listens on port 7000) remain usable as players when their name differs.
657 _, model = get_model_info(discovery_info)
658 return model == "ShairportSync" and discovery_info.port in receiver_ports
659
660 async def _handle_companion_service_state_change(
661 self,
662 name: str,
663 state_change: ServiceStateChange,
664 info: AsyncServiceInfo | None,
665 ) -> None:
666 """Associate a Companion service with its controlled AirPlay player."""
667 if state_change == ServiceStateChange.Removed:
668 # Keep the last endpoint while the device sleeps. Some devices can
669 # withdraw services from mDNS before Companion reports the asleep
670 # state, but the existing endpoint is still needed to wake them.
671 return
672 if info is None:
673 return
674
675 addresses = self._cache_control_service(self._companion_info_by_address, info)
676 player = self._find_airplay_player(addresses)
677 if isinstance(player, AirPlayControlPlayer):
678 await player.set_companion_discovery_info(info)
679
680 async def _handle_mrp_service_state_change(
681 self,
682 name: str,
683 state_change: ServiceStateChange,
684 info: AsyncServiceInfo | None,
685 ) -> None:
686 """Associate an MRP service with its controlled AirPlay player."""
687 if state_change == ServiceStateChange.Removed or info is None:
688 return
689 addresses = self._cache_control_service(self._mrp_info_by_address, info)
690 player = self._find_airplay_player(addresses)
691 if isinstance(player, AirPlayControlPlayer):
692 await player.set_mrp_discovery_info(info)
693
694 async def _get_related_discovery_info(
695 self,
696 service_type: str,
697 info_by_address: dict[str, AsyncServiceInfo],
698 player_addresses: set[str],
699 display_name: str,
700 ) -> AsyncServiceInfo | None:
701 """Return a related control service from cache or mDNS discovery."""
702 for address in player_addresses:
703 if discovery_info := info_by_address.get(address):
704 return discovery_info
705 if discovery_info := await self._find_cached_discovery_info(
706 service_type,
707 player_addresses,
708 ):
709 self._cache_control_service(info_by_address, discovery_info)
710 return discovery_info
711 discovery_info = await self.mass.discovery.async_find_mdns_service(
712 service_type,
713 display_name,
714 )
715 if discovery_info is None:
716 discovery_info = await self._find_cached_discovery_info(
717 service_type,
718 player_addresses,
719 )
720 if discovery_info is None:
721 return None
722 if player_addresses.isdisjoint(discovery_info.parsed_addresses()):
723 return None
724 self._cache_control_service(info_by_address, discovery_info)
725 return discovery_info
726
727 async def _find_cached_discovery_info(
728 self,
729 service_type: str,
730 player_addresses: set[str],
731 ) -> AsyncServiceInfo | None:
732 """Find a cached mDNS service sharing an address with the AirPlay endpoint."""
733 service_type_lower = service_type.lower()
734 for mdns_name in set(self.mass.discovery.aiozc.zeroconf.cache.cache):
735 if service_type_lower not in mdns_name or mdns_name == service_type_lower:
736 continue
737 discovery_info = AsyncServiceInfo(service_type, mdns_name)
738 if not await discovery_info.async_request(
739 self.mass.discovery.aiozc.zeroconf,
740 3000,
741 ):
742 continue
743 if not player_addresses.isdisjoint(discovery_info.parsed_addresses()):
744 return discovery_info
745 return None
746
747 def _find_airplay_player(self, addresses: set[str]) -> AirPlayPlayer | None:
748 """Return the AirPlay player matching any advertised address."""
749 return next(
750 (
751 candidate
752 for candidate in self.get_players()
753 if not addresses.isdisjoint(
754 {
755 candidate.address,
756 *self._get_discovery_addresses(
757 candidate.airplay_discovery_info,
758 candidate.raop_discovery_info,
759 candidate.mrp_discovery_info
760 if isinstance(candidate, AirPlayControlPlayer)
761 else None,
762 ),
763 }
764 )
765 ),
766 None,
767 )
768
769 @staticmethod
770 def _cache_control_service(
771 info_by_address: dict[str, AsyncServiceInfo],
772 info: AsyncServiceInfo,
773 ) -> set[str]:
774 """Cache a control service by address and return its current addresses."""
775 addresses = set(info.parsed_addresses())
776 # Drop addresses this service no longer advertises (e.g. after a DHCP
777 # change), so a stale entry can never classify a future device that
778 # gets the old address assigned.
779 for stale_address in [
780 address
781 for address, cached in info_by_address.items()
782 if cached.name == info.name and address not in addresses
783 ]:
784 del info_by_address[stale_address]
785 for address in addresses:
786 info_by_address[address] = info
787 return addresses
788
789 @staticmethod
790 def _get_discovery_addresses(
791 *discovery_infos: AsyncServiceInfo | None,
792 ) -> set[str]:
793 """Return all IP addresses advertised by the given mDNS services."""
794 return {
795 address
796 for discovery_info in discovery_infos
797 if discovery_info is not None
798 for address in discovery_info.parsed_addresses()
799 }
800
801 async def _register_dacp_service(self) -> None:
802 """
803 Register the DACP ActiveRemote mDNS service, reclaiming a stale name if needed.
804
805 The service name is derived from the (persistent) server id, so it is identical
806 across every reload and restart. A previous registration that was not cleanly
807 torn down - a reload racing a slow initial load, or a leftover from a prior crash -
808 keeps the same name in the zeroconf cache and makes a plain register raise
809 NonUniqueNameException. In that case we broadcast a goodbye for the name to flush
810 the stale record, then register again.
811 """
812 aiozc = self.mass.discovery.aiozc
813 try:
814 await aiozc.async_register_service(self._dacp_info)
815 return
816 except NonUniqueNameException:
817 self.logger.debug(
818 "DACP service %s already advertised - reclaiming stale registration",
819 self._dacp_info.name,
820 )
821 await aiozc.async_unregister_service(self._dacp_info)
822 await asyncio.sleep(DACP_RECLAIM_DELAY)
823 await aiozc.async_register_service(self._dacp_info)
824
825 async def _start_ptp_daemon(self) -> None:
826 """Spawn the shared PTP clock daemon (cliairplay --ptp-daemon)."""
827 try:
828 cli_binary = await get_cli_binary()
829 except RuntimeError as err:
830 self.logger.warning(
831 "cliairplay binary unavailable (%s) - "
832 "PTP timing is degraded, AirPlay 2 streams fall back to NTP",
833 err,
834 )
835 return
836 # Advertise the same grandmaster identity as the per-stream sessions
837 # (which derive their PTP clock id from --dacp), so receivers see one
838 # consistent clock whether the daemon or an in-process engine serves it.
839 args = [cli_binary, "--ptp-daemon", "--dacp", self.dacp_id]
840 if_arg = await self.mass.streams.get_source_ip()
841 if if_arg:
842 args += ["--if", if_arg]
843 # The daemon runs quiet by default: its per-packet PTP tracing
844 # (Announce/Sync/Delay_Req, ~10 lines/s) needs BOTH verbose logging and
845 # the dedicated opt-in, so ordinary verbose sessions are not flooded
846 # with timing chatter that only matters for clock-sync debugging.
847 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL) and self.config.get_value(
848 CONF_VERBOSE_PTP_LOGGING
849 ):
850 args += ["--debug", "10"]
851 # The binding the daemon ends up with is the first thing needed when triaging
852 # timing issues from a user's log, and it is invisible otherwise: --if is left
853 # out entirely for a default bind ip.
854 self.logger.debug("Starting shared PTP clock daemon: if=%s", if_arg or "<all interfaces>")
855 daemon = AsyncProcess(args, stdout=True, stderr=True, name="cliairplay-ptp-daemon")
856 # (Re)gate readiness for this daemon instance: not ready until a reader
857 # sees the "daemon up" line (a restart clears any previous readiness).
858 if self._ptp_daemon_ready is None:
859 self._ptp_daemon_ready = asyncio.Event()
860 else:
861 self._ptp_daemon_ready.clear()
862 self._reset_ptp_daemon_warn_budget()
863 await daemon.start()
864 self._ptp_daemon = daemon
865 self._ptp_daemon_started = time.monotonic()
866 daemon.attach_stderr_reader(self.mass.create_task(self._ptp_daemon_stderr_reader(daemon)))
867 self._ptp_daemon_stdout_task = self.mass.create_task(self._ptp_daemon_stdout_reader(daemon))
868 self.mass.create_task(self._ptp_daemon_monitor(daemon))
869
870 async def _ptp_daemon_stderr_reader(self, daemon: AsyncProcess) -> None:
871 """Forward PTP daemon stderr output to the debug log."""
872 async for line in daemon.iter_stderr():
873 self._handle_ptp_daemon_line(line)
874
875 async def _ptp_daemon_stdout_reader(self, daemon: AsyncProcess) -> None:
876 """Drain (and debug-log) the PTP daemon stdout pipe."""
877 buffer = b""
878 while chunk := await daemon.read(1024):
879 buffer += chunk
880 while b"\n" in buffer:
881 raw_line, buffer = buffer.split(b"\n", 1)
882 if line := raw_line.decode("utf-8", errors="ignore").strip():
883 self._handle_ptp_daemon_line(line)
884
885 def _handle_ptp_daemon_line(self, line: str) -> None:
886 """Log a PTP daemon output line and detect its readiness signal."""
887 # Routine daemon output is verbose-only so it never floods a user's log
888 # (the per-packet timing trace only runs at verbose in the first place).
889 lowered = line.lower()
890 if any(marker in lowered for marker in CLI_PROBLEM_MARKERS):
891 self._warn_ptp_daemon_line(line)
892 else:
893 self.logger.log(VERBOSE_LOG_LEVEL, "PTP daemon: %s", line)
894 # The readiness marker is matched on either pipe: the daemon's diagnostic
895 # output is not contractually stdout-vs-stderr, so both readers feed this
896 # handler and setting the event is idempotent.
897 if (
898 (event := self._ptp_daemon_ready) is not None
899 and not event.is_set()
900 and PTP_DAEMON_READY_MARKER in line
901 ):
902 self.logger.debug("Shared PTP clock daemon reported ready")
903 event.set()
904
905 def _reset_ptp_daemon_warn_budget(self) -> None:
906 """
907 Give a newly spawned daemon a full warning budget of its own.
908
909 The budget is per daemon, not per provider: a daemon that spent it
910 before crashing would otherwise have its replacement's startup failure -
911 the one line worth reading - suppressed for the rest of the window.
912 Whatever the old daemon had held back is reported on the way out rather
913 than dropped.
914 """
915 if self._ptp_daemon_warns_suppressed:
916 self.logger.warning(
917 "PTP daemon: %d further problem line(s) from the previous daemon were "
918 "suppressed; enable verbose logging to see them all",
919 self._ptp_daemon_warns_suppressed,
920 )
921 self._ptp_daemon_warn_window_start = None
922 self._ptp_daemon_warns_in_window = 0
923 self._ptp_daemon_warns_suppressed = 0
924
925 def _warn_ptp_daemon_line(self, line: str) -> None:
926 """
927 Warn about a daemon line that reads like a problem, at a bounded rate.
928
929 The markers are broad by design, and "error" is ordinary vocabulary in
930 clock telemetry (offset error, path delay error), so one matching line
931 in the daemon's per-packet trace would fill a user's log with warnings.
932 A burst still gets through - which is what a real one-shot daemon
933 failure looks like - and the rest of the window is counted and reported
934 once instead. Suppressed lines still reach the verbose log.
935
936 :param line: The daemon output line that matched a problem marker.
937 """
938 now = time.monotonic()
939 window_start = self._ptp_daemon_warn_window_start
940 # None means no window is open yet, which is not the same as one that
941 # started at monotonic zero: time.monotonic() counts from boot on Linux,
942 # so a server started in the first minute of uptime would otherwise get
943 # a first window shorter than the rest.
944 if window_start is None or now - window_start >= PTP_DAEMON_WARN_WINDOW:
945 if self._ptp_daemon_warns_suppressed:
946 self.logger.warning(
947 "PTP daemon: %d further problem line(s) were suppressed over the last "
948 "%.0fs; enable verbose logging to see them all",
949 self._ptp_daemon_warns_suppressed,
950 PTP_DAEMON_WARN_WINDOW,
951 )
952 self._ptp_daemon_warn_window_start = now
953 self._ptp_daemon_warns_in_window = 0
954 self._ptp_daemon_warns_suppressed = 0
955 if self._ptp_daemon_warns_in_window < PTP_DAEMON_WARN_BURST:
956 self._ptp_daemon_warns_in_window += 1
957 self.logger.warning("PTP daemon: %s", line)
958 return
959 self._ptp_daemon_warns_suppressed += 1
960 self.logger.log(VERBOSE_LOG_LEVEL, "PTP daemon: %s", line)
961
962 async def _ptp_daemon_monitor(self, daemon: AsyncProcess) -> None:
963 """Watch the PTP daemon process and restart it once if it crashes."""
964 returncode = await daemon.wait()
965 if self._ptp_daemon_stop_requested or self._ptp_daemon is not daemon:
966 return
967 self._ptp_daemon = None
968 # Crash detected: drop readiness immediately so a session starting during
969 # the restart window degrades consistently instead of attaching to a dead
970 # clock (a restart re-sets it once the new daemon reports ready).
971 if self._ptp_daemon_ready is not None:
972 self._ptp_daemon_ready.clear()
973 runtime = time.monotonic() - self._ptp_daemon_started
974 if runtime < 5:
975 # immediate exit: UDP 319/320 already taken or missing privileges
976 # (root or CAP_NET_BIND_SERVICE) - streams fall back to their
977 # in-process timing engine, so playback keeps working.
978 self.logger.warning(
979 "PTP clock daemon could not start (exit code %s) - PTP timing is degraded. "
980 "Multi-room sync of native AirPlay 2 players may drift; ensure UDP ports "
981 "319/320 are free and run the server as root or grant cliairplay "
982 "CAP_NET_BIND_SERVICE.",
983 returncode,
984 )
985 return
986 if not self._ptp_daemon_restarted:
987 self._ptp_daemon_restarted = True
988 self.logger.warning(
989 "PTP clock daemon stopped unexpectedly (exit code %s) - restarting", returncode
990 )
991 await self._start_ptp_daemon()
992 return
993 self.logger.warning(
994 "PTP clock daemon stopped again (exit code %s) - giving up. "
995 "PTP timing is degraded until the provider is reloaded.",
996 returncode,
997 )
998
999 async def _handle_dacp_request( # noqa: PLR0915
1000 self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
1001 ) -> None:
1002 """Handle new connection on the socket."""
1003 try:
1004 raw_request = b""
1005 while recv := await reader.read(1024):
1006 raw_request += recv
1007 if len(recv) < 1024:
1008 break
1009 if not raw_request:
1010 # Some device (Phorus PS10) seems to send empty request
1011 # Maybe as a ack message? we have nothing to do here with empty request
1012 # so we return early.
1013 return
1014
1015 request = raw_request.decode("UTF-8")
1016 if "\r\n\r\n" in request:
1017 headers_raw, body = request.split("\r\n\r\n", 1)
1018 else:
1019 headers_raw = request
1020 body = ""
1021 headers_split = headers_raw.split("\r\n")
1022 headers = {}
1023 for line in headers_split[1:]:
1024 if ":" not in line:
1025 continue
1026 x, y = line.split(":", 1)
1027 headers[x.strip()] = y.strip()
1028 active_remote = headers.get("Active-Remote")
1029 _, path, _ = headers_split[0].split(" ")
1030 # lookup airplay player by active remote id
1031 player: AirPlayPlayer | None = next(
1032 (
1033 x
1034 for x in self.get_players()
1035 if x.stream and x.stream.active_remote_id == active_remote
1036 ),
1037 None,
1038 )
1039 self.logger.debug(
1040 "DACP request for %s (%s): %s -- %s",
1041 player.name if player else "UNKNOWN PLAYER",
1042 active_remote,
1043 path,
1044 body,
1045 )
1046 # machine-parseable capture of raw DACP traffic for building replay test fixtures
1047 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
1048 capture = {
1049 "player": player.name if player else None,
1050 "active_remote": active_remote,
1051 "method": headers_split[0].split(" ", 1)[0],
1052 "path": path,
1053 "headers": headers,
1054 "body": body,
1055 "raw_b64": base64.b64encode(raw_request).decode("ascii"),
1056 }
1057 self.logger.log(VERBOSE_LOG_LEVEL, "AIRPLAY_DACP_CAPTURE %s", json.dumps(capture))
1058 if not player:
1059 return
1060 if player.protocol_parent_id and (
1061 parent := self.mass.players.get_player(player.protocol_parent_id)
1062 ):
1063 parent_player = parent
1064 else:
1065 parent_player = player
1066
1067 player_id = player.player_id
1068 if path == "/ctrl-int/1/nextitem":
1069 self.handle_remote_command(player, AirPlayRemoteCommand.NEXT)
1070 elif path == "/ctrl-int/1/previtem":
1071 self.handle_remote_command(player, AirPlayRemoteCommand.PREVIOUS)
1072 elif path == "/ctrl-int/1/play":
1073 self.handle_remote_command(player, AirPlayRemoteCommand.PLAY)
1074 elif path == "/ctrl-int/1/playpause":
1075 self.handle_remote_command(player, AirPlayRemoteCommand.PLAY_PAUSE)
1076 elif path == "/ctrl-int/1/stop":
1077 self.mass.create_task(self.mass.players.cmd_stop(player_id))
1078 elif path == "/ctrl-int/1/volumeup":
1079 self.mass.create_task(self.mass.players.cmd_volume_up(player_id))
1080 elif path == "/ctrl-int/1/volumedown":
1081 self.mass.create_task(self.mass.players.cmd_volume_down(player_id))
1082 elif path == "/ctrl-int/1/shuffle_songs":
1083 active_queue = self.mass.players.get_active_queue(player)
1084 if not active_queue:
1085 return
1086 await self.mass.player_queues.set_shuffle(
1087 active_queue.queue_id, not active_queue.shuffle_enabled
1088 )
1089 elif path == "/ctrl-int/1/pause":
1090 self.handle_remote_command(player, AirPlayRemoteCommand.PAUSE)
1091 elif path == "/ctrl-int/1/discrete-pause":
1092 # Some devices send discrete-pause right before device-prevent-playback=1
1093 # when switching to another source. We debounce the pause to avoid
1094 # unnecessary pause commands that would interfere with source switching
1095 # so we only process the pause command if we don't receive a
1096 # prevent-playback=1 within a short time window.
1097 if player.state.playback_state == PlaybackState.PLAYING:
1098 self.mass.call_later(
1099 1.0,
1100 self.mass.players.cmd_pause,
1101 player_id,
1102 task_id=f"debounced_pause_{player_id}",
1103 )
1104 elif "dmcp.device-volume=" in path and not player.ignore_volume_reports:
1105 # This is a bit annoying as this can be either the device confirming a new volume
1106 # we've sent or the device requesting a new volume itself.
1107 # In case of a small rounding difference, we ignore this,
1108 # to prevent an endless pingpong of volume changes
1109 airplay_volume = float(path.split("dmcp.device-volume=", 1)[-1])
1110 if airplay_volume <= AIRPLAY_VOLUME_MUTE:
1111 player._attr_volume_muted = True
1112 if player.stream and player.stream.running:
1113 self.mass.create_task(player.stream.send_cli_command("VOLUME=0"))
1114 player.update_state()
1115 else:
1116 if player.volume_muted:
1117 player._attr_volume_muted = False
1118 if player.stream and player.stream.running:
1119 self.mass.create_task(
1120 player.stream.send_cli_command(f"VOLUME={player.volume_level or 0}")
1121 )
1122 volume = convert_airplay_volume(airplay_volume)
1123 player.update_volume_from_device(volume)
1124 elif "dmcp.volume=" in path:
1125 # volume change request from device (e.g. volume buttons)
1126 volume = int(path.split("dmcp.volume=", 1)[-1])
1127 player.update_volume_from_device(volume)
1128 elif "device-prevent-playback=1" in path:
1129 # device switched to another source (or is powered off)
1130 # Cancel any pending debounced pause since prevent-playback takes precedence
1131 self.mass.cancel_timer(f"debounced_pause_{player_id}")
1132 # Ignore during stream transition (stale message from old CLI process)
1133 if player._transitioning or not player.stream:
1134 self.logger.debug("Ignoring prevent-playback during stream transition")
1135 elif player.stream.prevent_playback:
1136 # Already handling a prevent-playback for this stream
1137 # (duplicate message while ungroup/stop is still in progress)
1138 self.logger.debug("Ignoring duplicate prevent-playback for %s", player.name)
1139 elif not player.stream.connected:
1140 # Some devices (e.g. Denon AVR-X2700H) emit a transient
1141 # prevent-playback=1/=0 pair during RAOP session setup.
1142 # A real "device switched off / source switched" event only happens
1143 # once the stream is actually established, so ignore these.
1144 self.logger.debug(
1145 "Ignoring prevent-playback for %s - stream not yet established",
1146 player.name,
1147 )
1148 else:
1149 player.stream.prevent_playback = True
1150 if player.stream.session:
1151 self.logger.debug(
1152 "Prevent playback command detected for player %s",
1153 player.name,
1154 )
1155 if player.synced_to or parent_player.state.active_group:
1156 self.mass.create_task(
1157 self.mass.players.cmd_ungroup(parent_player.player_id)
1158 )
1159 else:
1160 self.mass.create_task(player.stream.stop())
1161 elif "device-prevent-playback=0" in path:
1162 # device reports that its ready for playback again
1163 # use a debounced reset to avoid race conditions where a quick
1164 # prevent-playback=0 between duplicate prevent-playback=1 messages
1165 # would reset the flag and allow the second message to act
1166 if (stream := player.stream) and stream.prevent_playback:
1167 self.mass.call_later(
1168 5,
1169 setattr,
1170 stream,
1171 "prevent_playback",
1172 False,
1173 task_id=f"reset_prevent_playback_{player_id}",
1174 )
1175
1176 # send response
1177 date_str = utc().strftime("%a, %-d %b %Y %H:%M:%S")
1178 response = (
1179 f"HTTP/1.0 204 No Content\r\nDate: {date_str} "
1180 "GMT\r\nDAAP-Server: iTunes/7.6.2 (Windows; N;)\r\nContent-Type: "
1181 "application/x-dmap-tagged\r\nContent-Length: 0\r\n"
1182 "Connection: close\r\n\r\n"
1183 )
1184 writer.write(response.encode())
1185 await writer.drain()
1186 finally:
1187 writer.close()
1188 with suppress(Exception):
1189 await writer.wait_closed()
1190
1191 def _drop_unverified_password_markers(self) -> None:
1192 """
1193 Clear the stored "password rejected" verdicts left by earlier releases, once.
1194
1195 Those releases marked a player whenever the binary reported an auth-shaped
1196 rejection, without separating a password challenge from a device that
1197 refused the handshake outright. The refusals put players that have no
1198 password at all into a setup flow only a password could leave, so the
1199 verdicts are dropped and left to be earned again on the next connect.
1200 """
1201 if self.mass.config.get_raw_provider_config_value(
1202 self.instance_id, CONF_PASSWORD_MARKERS_REVIEWED, False
1203 ):
1204 return
1205 # walks the stored configs rather than get_player_configs(), which drops
1206 # every protocol player - the type each non-Apple receiver is registered
1207 # as - and only lists the ones discovered so far
1208 for player_id, raw_conf in self.mass.config.get(CONF_PLAYERS, {}).items():
1209 if not isinstance(raw_conf, dict) or raw_conf.get("provider") != self.instance_id:
1210 continue
1211 if not self.mass.config.get_raw_player_config_value(
1212 player_id, CONF_PASSWORD_INVALID, False
1213 ):
1214 continue
1215 self.logger.info("Clearing the unverified password marker on %s", player_id)
1216 self.mass.config.set_raw_player_config_value(player_id, CONF_PASSWORD_INVALID, False)
1217 # last, so a failure part-way through leaves the review to be retried on
1218 # the next load instead of stranding the players it never reached
1219 self.mass.config.set_raw_provider_config_value(
1220 self.instance_id, CONF_PASSWORD_MARKERS_REVIEWED, True
1221 )
1222
1223 def _reset_auto_pinned_compat_modes(self) -> None:
1224 """
1225 Reset the compatibility-mode pins left by earlier releases, once.
1226
1227 Those releases switched a player's streaming mode to compatibility mode
1228 themselves when its native control channel failed, which a network
1229 dropout of the device also triggers. The pin outlived the dropout and
1230 put the player on a lane many devices reject outright, so the
1231 machine-written values are returned to Automatic; a user who wants
1232 compatibility mode can simply pin it again.
1233 """
1234 if self.mass.config.get_raw_provider_config_value(
1235 self.instance_id, CONF_COMPAT_PINS_REVIEWED, False
1236 ):
1237 return
1238 # walks the stored configs rather than get_player_configs(), which drops
1239 # every protocol player - the type each non-Apple receiver is registered
1240 # as - and only lists the ones discovered so far
1241 for player_id, raw_conf in self.mass.config.get(CONF_PLAYERS, {}).items():
1242 if not isinstance(raw_conf, dict) or raw_conf.get("provider") != self.instance_id:
1243 continue
1244 if (
1245 self.mass.config.get_raw_player_config_value(player_id, CONF_STREAMING_MODE)
1246 != STREAMING_MODE_AP2_COMPAT
1247 ):
1248 continue
1249 self.logger.info("Resetting the streaming mode of %s back to Automatic", player_id)
1250 self.mass.config.set_raw_player_config_value(
1251 player_id, CONF_STREAMING_MODE, STREAMING_MODE_AUTO
1252 )
1253 # last, so a failure part-way through leaves the reset to be retried on
1254 # the next load instead of stranding the players it never reached
1255 self.mass.config.set_raw_provider_config_value(
1256 self.instance_id, CONF_COMPAT_PINS_REVIEWED, True
1257 )
1258