/
/
/
1"""Control-capable player implementation for the AirPlay provider."""
2
3from __future__ import annotations
4
5import asyncio
6import contextlib
7import time
8from collections.abc import Awaitable, Callable
9from ipaddress import AddressValueError, IPv4Address
10from typing import TYPE_CHECKING, Final
11
12import pyatv
13from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
14from music_assistant_models.enums import (
15 ConfigEntryType,
16 ImageType,
17 MediaType,
18 PlaybackState,
19 PlayerFeature,
20 PlayerType,
21)
22from music_assistant_models.errors import MediaNotFoundError, PlayerCommandFailed
23from music_assistant_models.media_items import MediaItemImage
24from music_assistant_models.player import PlayerSource
25from pyatv import exceptions as pyatv_exceptions
26from pyatv.conf import AppleTV as AppleTVConfig
27from pyatv.conf import ManualService
28from pyatv.const import (
29 DeviceState,
30 FeatureName,
31 FeatureState,
32 PairingRequirement,
33 PowerState,
34 Protocol,
35)
36from pyatv.const import (
37 MediaType as PyatvMediaType,
38)
39from pyatv.interface import (
40 AppleTV,
41 AudioListener,
42 DeviceListener,
43 OutputDevice,
44 PairingHandler,
45 Playing,
46 PowerListener,
47 PushListener,
48)
49from pyatv.settings import MrpTunnel
50from pyatv.storage.memory_storage import MemoryStorage
51
52from music_assistant.models.player import PlayerMedia
53from music_assistant.models.setup_flow import AbortFlow
54
55from .constants import (
56 CONF_COMPANION_CREDENTIALS,
57 CONF_COMPANION_PAIRING_PIN,
58 CONF_MRP_CREDENTIALS,
59 CONF_MRP_PAIRING_PIN,
60 CONF_NATIVE_MRP_CREDENTIALS,
61 CONF_STORED_VOLUME,
62 EXTERNAL_ARTWORK_PATH_PREFIX,
63 FALLBACK_VOLUME,
64 PAIRING_PIN_FORMAT,
65)
66from .helpers import (
67 get_decoded_property,
68 supports_companion_pairing,
69 supports_mrp_service,
70 supports_mrp_tunnel,
71 supports_transient_mrp,
72)
73from .player import AirPlayPlayer
74
75if TYPE_CHECKING:
76 from zeroconf.asyncio import AsyncServiceInfo
77
78 from music_assistant.models.setup_flow import SetupSession
79
80 from .provider import AirPlayProvider
81
82_CONTROL_RECONNECT_DELAY: Final[float] = 30.0
83_WAKE_TIMEOUT: Final[float] = 10.0
84
85# mDNS TXT keys whose values change with transient playback, session or group
86# state - most notably `flags`, which a receiver toggles while it is receiving a
87# stream. They never affect how the control connection is established, so a
88# change must not force a reconnect: doing so made Apple TVs tear down and
89# re-establish the control channel on every stream start and stop, each time
90# surfacing the on-screen pairing code. Compared case-insensitively (RFC 6763).
91_VOLATILE_DISCOVERY_KEYS: Final = frozenset({"flags", "gcgl", "gid", "igl", "gpn", "pgcgl"})
92
93_CONNECTION_ERRORS = (
94 pyatv_exceptions.AuthenticationError,
95 pyatv_exceptions.BackOffError,
96 pyatv_exceptions.ConnectionFailedError,
97 pyatv_exceptions.DeviceIdMissingError,
98 pyatv_exceptions.InvalidConfigError,
99 pyatv_exceptions.InvalidCredentialsError,
100 pyatv_exceptions.InvalidResponseError,
101 pyatv_exceptions.NoCredentialsError,
102 pyatv_exceptions.NoServiceError,
103 pyatv_exceptions.OperationTimeoutError,
104 pyatv_exceptions.ProtocolError,
105 OSError,
106 TimeoutError,
107 ValueError,
108)
109_COMMAND_ERRORS = (
110 pyatv_exceptions.AuthenticationError,
111 pyatv_exceptions.BlockedStateError,
112 pyatv_exceptions.CommandError,
113 pyatv_exceptions.ConnectionLostError,
114 pyatv_exceptions.InvalidStateError,
115 pyatv_exceptions.NotSupportedError,
116 pyatv_exceptions.OperationTimeoutError,
117 pyatv_exceptions.ProtocolError,
118 OSError,
119 TimeoutError,
120)
121
122
123class AirPlayControlPlayer(AirPlayPlayer):
124 """AirPlay player with independent device monitoring and control."""
125
126 _attr_type = PlayerType.PLAYER
127
128 def __init__( # noqa: PLR0913
129 self,
130 provider: AirPlayProvider,
131 player_id: str,
132 raop_discovery_info: AsyncServiceInfo | None,
133 airplay_discovery_info: AsyncServiceInfo | None,
134 companion_discovery_info: AsyncServiceInfo | None,
135 mrp_discovery_info: AsyncServiceInfo | None,
136 address: str,
137 display_name: str,
138 manufacturer: str,
139 model: str,
140 initial_volume: int,
141 ) -> None:
142 """Initialize a control-capable AirPlay player."""
143 self.companion_discovery_info = companion_discovery_info
144 self.mrp_discovery_info = mrp_discovery_info
145 self._companion_device: AppleTV | None = None
146 self._mrp_device: AppleTV | None = None
147 super().__init__(
148 provider=provider,
149 player_id=player_id,
150 raop_discovery_info=raop_discovery_info,
151 airplay_discovery_info=airplay_discovery_info,
152 address=address,
153 display_name=display_name,
154 manufacturer=manufacturer,
155 model=model,
156 initial_volume=initial_volume,
157 )
158 self._companion_listener: _AirPlayStateListener | None = None
159 self._mrp_state_listener: _AirPlayStateListener | None = None
160 self._mrp_push_listener: _AirPlayPushListener | None = None
161 self._connection_task: asyncio.Task[None] | None = None
162 self._connection_lock = asyncio.Lock()
163 self._power_on_event = asyncio.Event()
164 self._volume_before_mute: int | None = None
165 self._disconnecting = False
166 self._restart_connections = False
167 self._unloading = False
168 # invoked (if set) whenever the Companion connection comes up or goes down,
169 # so an observer (e.g. the dashboard adapter) can re-evaluate its state
170 self.on_companion_state_change: Callable[[], None] | None = None
171
172 @property
173 def companion_pairing_supported(self) -> bool:
174 """Return whether this device advertises Companion PIN pairing."""
175 return supports_companion_pairing(self.companion_discovery_info)
176
177 @property
178 def mrp_pairing_supported(self) -> bool:
179 """Return whether MRP playback monitoring can be paired."""
180 endpoint = self._mrp_endpoint
181 if endpoint is None:
182 return False
183 discovery_info, protocol = endpoint
184 if protocol == Protocol.MRP:
185 allow_pairing = get_decoded_property(discovery_info, "AllowPairing") or "no"
186 return allow_pairing.lower() == "yes"
187 return bool(
188 not self._uses_transient_mrp
189 and protocol == Protocol.AirPlay
190 and self._is_airplay2_capable
191 and discovery_info.decoded_properties.get("acl", "0") != "1"
192 )
193
194 @property
195 def supported_features(self) -> set[PlayerFeature]:
196 """Return the supported features of this controlled device."""
197 features = {*super().supported_features}
198 if self._device_for_feature(FeatureName.Next) or self._device_for_feature(
199 FeatureName.Previous
200 ):
201 features.add(PlayerFeature.NEXT_PREVIOUS)
202 # POWER is advertised only when it can actually be served: a connected
203 # control channel exposing power commands, or stored Companion
204 # credentials (so the feature does not flap while (re)connecting).
205 if (
206 self.get_setup_value(CONF_COMPANION_CREDENTIALS)
207 or self._device_for_power_feature(FeatureName.TurnOn)
208 or self._device_for_power_feature(FeatureName.TurnOff)
209 ):
210 features.add(PlayerFeature.POWER)
211 if not self._stream_active and not self._device_for_feature(FeatureName.SetVolume):
212 features.discard(PlayerFeature.VOLUME_MUTE)
213 return features
214
215 @property
216 def companion_connected(self) -> bool:
217 """Return whether the Companion control channel is currently connected."""
218 return self._companion_device is not None
219
220 async def get_config_entries(self) -> list[ConfigEntry]:
221 """Return player configuration entries."""
222 # Companion/MRP pairing is handled by the interactive setup flow
223 # (run_setup_flow) and stored in setup_data, no longer as config entries.
224 return await super().get_config_entries()
225
226 async def run_setup_flow(self, session: SetupSession) -> None:
227 """
228 Run the interactive setup flow for this controlled AirPlay player.
229
230 The streaming pairing (if any) is the required "device code" that gates
231 ``needs_setup``; the optional Companion (remote control) and MRP (playback
232 monitoring) pairings are offered afterwards as sequential, skippable steps.
233 Re-launching from the player settings re-offers every pairing, so a stored
234 pairing can be redone (replaced) when it went stale.
235
236 :param session: The setup flow session used to interact with the user.
237 """
238 collected: dict[str, ConfigValueType] = {}
239 await self._run_streaming_pairing(session, collected)
240 await self._run_companion_pairing(session, collected)
241 await self._run_mrp_pairing(session, collected)
242 await session.finish(collected)
243 if collected.keys() & {
244 CONF_COMPANION_CREDENTIALS,
245 CONF_MRP_CREDENTIALS,
246 CONF_NATIVE_MRP_CREDENTIALS,
247 }:
248 # bring the freshly paired control channel up right away
249 self._schedule_connection(force=True)
250
251 async def power(self, powered: bool) -> None:
252 """Turn the controlled device on or off."""
253 feature = FeatureName.TurnOn if powered else FeatureName.TurnOff
254 device = self._device_for_power_feature(feature)
255 if device is None:
256 raise PlayerCommandFailed(f"Power control is unavailable for {self.display_name}")
257 if powered:
258 self._power_on_event.clear()
259 await self._run_control_command(device.power.turn_on(), "turn on")
260 await self._wait_for_wake()
261 else:
262 await self._run_control_command(device.power.turn_off(), "turn off")
263
264 async def play(self) -> None:
265 """Resume Music Assistant or external playback."""
266 await self._wake_for_playback()
267 if self._stream_active:
268 await super().play()
269 return
270 device = self._device_for_feature(FeatureName.Play)
271 if device is None:
272 device = self._device_for_feature(FeatureName.PlayPause)
273 if device is None:
274 raise PlayerCommandFailed(f"Play control is unavailable for {self.display_name}")
275 await self._run_control_command(device.remote_control.play_pause(), "play")
276 return
277 await self._run_control_command(device.remote_control.play(), "play")
278
279 async def pause(self) -> None:
280 """Pause Music Assistant or external playback."""
281 if self._stream_active:
282 await super().pause()
283 return
284 device = self._device_for_feature(FeatureName.Pause)
285 if device is None:
286 device = self._device_for_feature(FeatureName.PlayPause)
287 if device is None:
288 raise PlayerCommandFailed(f"Pause control is unavailable for {self.display_name}")
289 await self._run_control_command(device.remote_control.play_pause(), "pause")
290 return
291 await self._run_control_command(device.remote_control.pause(), "pause")
292
293 async def stop(self) -> None:
294 """Stop Music Assistant playback, or return the device to its home screen."""
295 if self._stream_active:
296 await super().stop()
297 return
298 # For external playback there is no real "stop"; returning to the home
299 # screen backgrounds the current app, which is the closest equivalent.
300 device = self._device_for_feature(FeatureName.Home)
301 if device is not None:
302 await self._run_control_command(device.remote_control.home(), "stop")
303 return
304 device = self._device_for_feature(FeatureName.Stop)
305 if device is not None:
306 await self._run_control_command(device.remote_control.stop(), "stop")
307 return
308 device = self._device_for_feature(FeatureName.Pause)
309 if device is not None:
310 await self._run_control_command(device.remote_control.pause(), "stop")
311 return
312 raise PlayerCommandFailed(f"Stop control is unavailable for {self.display_name}")
313
314 async def play_media(self, media: PlayerMedia) -> None:
315 """Wake the controlled device and start Music Assistant playback."""
316 await self._wake_for_playback()
317 await super().play_media(media)
318
319 async def volume_set(self, volume_level: int) -> None:
320 """Set stream or native device volume."""
321 if self._stream_active:
322 await super().volume_set(volume_level)
323 return
324 device = self._device_for_feature(FeatureName.SetVolume)
325 if device is None:
326 await super().volume_set(volume_level)
327 return
328 await self._run_volume_command(device.audio.set_volume(volume_level), "set volume")
329 self._handle_volume_update("command", volume_level)
330
331 async def volume_mute(self, muted: bool) -> None:
332 """Mute an active stream or native device volume."""
333 if self._stream_active:
334 await super().volume_mute(muted)
335 return
336 device = self._device_for_feature(FeatureName.SetVolume)
337 if device is None:
338 raise PlayerCommandFailed(f"Mute control is unavailable for {self.display_name}")
339 if muted:
340 if self.volume_muted:
341 return
342 if self.volume_level and self.volume_level > 0:
343 self._volume_before_mute = self.volume_level
344 await self._run_volume_command(device.audio.set_volume(0), "mute")
345 self._handle_volume_update("command", 0)
346 return
347 if not self.volume_muted:
348 return
349 volume = self._volume_before_mute or self.volume_level or FALLBACK_VOLUME
350 await self._run_volume_command(device.audio.set_volume(volume), "unmute")
351 self._handle_volume_update("command", volume)
352
353 async def next_track(self) -> None:
354 """Skip to the next item in external playback."""
355 device = self._device_for_feature(FeatureName.Next)
356 if device is None:
357 raise PlayerCommandFailed(f"Next control is unavailable for {self.display_name}")
358 await self._run_control_command(device.remote_control.next(), "skip to next")
359
360 async def previous_track(self) -> None:
361 """Return to the previous item in external playback."""
362 device = self._device_for_feature(FeatureName.Previous)
363 if device is None:
364 raise PlayerCommandFailed(f"Previous control is unavailable for {self.display_name}")
365 await self._run_control_command(device.remote_control.previous(), "skip to previous")
366
367 async def wake(self) -> None:
368 """Wake the device from sleep when it exposes power control."""
369 await self._wake_for_playback()
370
371 async def async_list_installed_app_ids(self) -> set[str] | None:
372 """
373 Return the bundle ids of the apps installed on the device.
374
375 Uses the Companion app-listing feature. Returns None when the app list cannot be
376 retrieved (Companion channel down or the query failed), so a caller can tell
377 "unknown" apart from "installed, but not this app".
378 """
379 device = self._device_for_feature(FeatureName.AppList)
380 if device is None:
381 return None
382 try:
383 apps = await device.apps.app_list()
384 except _COMMAND_ERRORS as err:
385 self.logger.debug("Unable to list installed apps for %s: %s", self.name, err)
386 return None
387 return {app.identifier for app in apps}
388
389 async def async_launch_app(self, bundle_id_or_url: str) -> None:
390 """
391 Launch an app (bundle id) or custom URL on the device over Companion.
392
393 :param bundle_id_or_url: A bundle id or a URL-scheme value to launch.
394 :raises PlayerCommandFailed: If app launching is unavailable or the launch fails.
395 """
396 device = self._device_for_feature(FeatureName.LaunchApp)
397 if device is None:
398 raise PlayerCommandFailed(f"App launching is unavailable for {self.display_name}")
399 await self._run_control_command(device.apps.launch_app(bundle_id_or_url), "launch app")
400
401 async def async_get_external_artwork(self, artwork_id: str) -> bytes:
402 """
403 Return artwork bytes for the current externally playing item.
404
405 :param artwork_id: Identifier of the artwork requested by the image proxy.
406 :raises MediaNotFoundError: If the artwork is stale or unavailable.
407 """
408 device = self._mrp_device
409 if (
410 device is None
411 or self._stream_active
412 or not self._feature_available(device, FeatureName.Artwork)
413 or device.metadata.artwork_id != artwork_id
414 ):
415 raise MediaNotFoundError("External AirPlay artwork is unavailable")
416 try:
417 artwork = await device.metadata.artwork()
418 except _COMMAND_ERRORS as err:
419 raise MediaNotFoundError("Unable to retrieve external AirPlay artwork") from err
420 if (
421 artwork is None
422 or not artwork.bytes
423 or self._mrp_device is not device
424 or self._stream_active
425 or device.metadata.artwork_id != artwork_id
426 ):
427 raise MediaNotFoundError("External AirPlay artwork is no longer current")
428 return artwork.bytes
429
430 def set_discovery_info(self, discovery_info: AsyncServiceInfo, display_name: str) -> None:
431 """Update AirPlay discovery data and reconnect device control if needed."""
432 previous_signature = self._service_signature(self.airplay_discovery_info)
433 previous_address = self.address
434 super().set_discovery_info(discovery_info, display_name)
435 if (
436 previous_signature != self._service_signature(self.airplay_discovery_info)
437 or previous_address != self.address
438 ):
439 self._schedule_connection(force=True)
440
441 async def set_companion_discovery_info(self, discovery_info: AsyncServiceInfo | None) -> None:
442 """Update Companion discovery data and reconnect the control channel."""
443 if self._service_signature(self.companion_discovery_info) == self._service_signature(
444 discovery_info
445 ):
446 return
447 self.companion_discovery_info = discovery_info
448 self.update_state()
449 self._schedule_connection(force=True)
450
451 async def set_mrp_discovery_info(self, discovery_info: AsyncServiceInfo | None) -> None:
452 """Update native MRP discovery data and reconnect playback monitoring."""
453 if self._service_signature(self.mrp_discovery_info) == self._service_signature(
454 discovery_info
455 ):
456 return
457 self.mrp_discovery_info = discovery_info
458 self.update_state()
459 self._schedule_connection(force=True)
460
461 async def on_config_updated(self) -> None:
462 """Reconnect control services when player configuration changes."""
463 await super().on_config_updated()
464 self._schedule_connection(force=True)
465
466 async def on_unload(self) -> None:
467 """Close control connections and pairing resources."""
468 self._unloading = True
469 if self._connection_task and not self._connection_task.done():
470 self._connection_task.cancel()
471 with contextlib.suppress(asyncio.CancelledError):
472 await self._connection_task
473 await self._disconnect_control_services()
474 await super().on_unload()
475
476 def _schedule_connection(self, *, force: bool = False) -> None:
477 """Start or restart the Apple service connection loop."""
478 if self._unloading:
479 return
480 if self._connection_task and not self._connection_task.done():
481 if not force:
482 return
483 self._connection_task.cancel()
484 self._restart_connections = self._restart_connections or force
485 self._connection_task = self.mass.create_task(
486 self._connection_loop,
487 task_id=f"airplay_apple_control_{self.player_id}",
488 abort_existing=force,
489 )
490
491 async def _connection_loop(self) -> None:
492 """Connect control services and retry transient failures."""
493 retry = True
494 while retry and not self._unloading:
495 retry = await self._connect_control_services()
496 if retry:
497 await asyncio.sleep(_CONTROL_RECONNECT_DELAY)
498
499 async def _connect_control_services(self) -> bool:
500 """Connect Companion and MRP independently."""
501 async with self._connection_lock:
502 if self._restart_connections:
503 self._restart_connections = False
504 await self._disconnect_control_services()
505 companion_retry, mrp_retry = await asyncio.gather(
506 self._connect_companion(),
507 self._connect_mrp(),
508 )
509 return companion_retry or mrp_retry
510
511 async def _connect_companion(self) -> bool:
512 """Connect the Companion control channel."""
513 if self._companion_device is not None:
514 return False
515 credentials = self.get_setup_value(CONF_COMPANION_CREDENTIALS)
516 if not credentials or not self.companion_discovery_info:
517 return False
518 config = self._build_config(
519 self.companion_discovery_info,
520 Protocol.Companion,
521 str(credentials),
522 PairingRequirement.Mandatory,
523 )
524 if config is None:
525 return False
526 try:
527 device = await pyatv.connect(config, self.mass.loop)
528 except pyatv_exceptions.AuthenticationError, pyatv_exceptions.InvalidCredentialsError:
529 self.logger.warning(
530 "Stored Companion credentials are no longer valid for %s",
531 self.display_name,
532 )
533 self._clear_stored_credentials(CONF_COMPANION_CREDENTIALS)
534 return False
535 except _CONNECTION_ERRORS as err:
536 self.logger.debug("Unable to connect Companion control for %s: %s", self.name, err)
537 return True
538
539 self._companion_device = device
540 listener = _AirPlayStateListener(self, device, "companion")
541 self._companion_listener = listener
542 device.listener = listener
543 device.power.listener = listener
544 device.audio.listener = listener
545 self._apply_initial_device_state(device, "companion")
546 self.update_state()
547 self.logger.debug("Connected Companion control for %s", self.display_name)
548 self._notify_companion_state_change()
549 return False
550
551 async def _connect_mrp(self) -> bool:
552 """Connect MRP playback monitoring."""
553 if self._mrp_device is not None:
554 return False
555 endpoint = self._mrp_endpoint
556 if endpoint is None:
557 return False
558 discovery_info, protocol = endpoint
559 credentials = self._mrp_credentials
560 if protocol == Protocol.AirPlay and credentials is None and not self._uses_transient_mrp:
561 return False
562 config = self._build_config(
563 discovery_info,
564 protocol,
565 str(credentials) if credentials else None,
566 PairingRequirement.NotNeeded,
567 )
568 if config is None:
569 return False
570 storage: MemoryStorage | None = None
571 if protocol == Protocol.AirPlay:
572 storage = MemoryStorage()
573 settings = await storage.get_settings(config)
574 settings.protocols.airplay.mrp_tunnel = MrpTunnel.Force
575 try:
576 device = await pyatv.connect(config, self.mass.loop, storage=storage)
577 except (
578 pyatv_exceptions.AuthenticationError,
579 pyatv_exceptions.InvalidCredentialsError,
580 ) as err:
581 self.logger.warning(
582 "Unable to authenticate playback monitoring for %s: %s", self.name, err
583 )
584 if credentials:
585 self._clear_stored_credentials(self._mrp_credentials_key)
586 return False
587 except _CONNECTION_ERRORS as err:
588 self.logger.debug("Unable to connect playback monitoring for %s: %s", self.name, err)
589 return True
590
591 if not self._feature_available(device, FeatureName.PushUpdates):
592 device.close()
593 self.logger.debug("Playback monitoring is not supported by %s", self.name)
594 return False
595
596 self._mrp_device = device
597 state_listener = _AirPlayStateListener(self, device, "mrp")
598 push_listener = _AirPlayPushListener(self, device)
599 self._mrp_state_listener = state_listener
600 self._mrp_push_listener = push_listener
601 device.listener = state_listener
602 device.power.listener = state_listener
603 device.audio.listener = state_listener
604 device.push_updater.listener = push_listener
605 device.push_updater.start()
606 self._apply_initial_device_state(device, "mrp")
607 try:
608 self._handle_playing_update(await device.metadata.playing())
609 except _CONNECTION_ERRORS as err:
610 self.logger.debug("Unable to read initial playback state for %s: %s", self.name, err)
611 self.logger.debug("Connected MRP playback monitoring for %s", self.display_name)
612 self.update_state()
613 return False
614
615 async def _disconnect_control_services(self) -> None:
616 """Close all active pyatv connections."""
617 self._disconnecting = True
618 companion_device = self._companion_device
619 mrp_device = self._mrp_device
620 self._companion_device = None
621 self._mrp_device = None
622 self._companion_listener = None
623 self._mrp_state_listener = None
624 self._mrp_push_listener = None
625 if mrp_device:
626 with contextlib.suppress(pyatv_exceptions.NotSupportedError):
627 if mrp_device.push_updater.active:
628 mrp_device.push_updater.stop()
629 mrp_device.close()
630 # _handle_connection_closed skips its cleanup for this device because
631 # _mrp_device was already detached above, so drop the external playback
632 # snapshot here or it survives forced reconnects indefinitely.
633 self._clear_external_state()
634 if companion_device:
635 companion_device.close()
636 self._disconnecting = False
637 if companion_device is not None:
638 self._notify_companion_state_change()
639
640 def _build_config(
641 self,
642 info: AsyncServiceInfo,
643 protocol: Protocol,
644 credentials: str | None,
645 pairing_requirement: PairingRequirement,
646 ) -> AppleTVConfig | None:
647 """Build a pyatv configuration from an existing mDNS record."""
648 address = self._control_address(info)
649 if address is None:
650 self.logger.debug("Device control requires an IPv4 address for %s", self.name)
651 return None
652 if info.port is None:
653 self.logger.debug("Device control service has no port for %s", self.name)
654 return None
655 properties = {
656 key: value for key, value in info.decoded_properties.items() if value is not None
657 }
658 config = AppleTVConfig(address, self.display_name)
659 config.add_service(
660 ManualService(
661 self.player_id,
662 protocol,
663 info.port,
664 properties,
665 credentials=credentials,
666 pairing_requirement=pairing_requirement,
667 )
668 )
669 return config
670
671 def _control_address(self, info: AsyncServiceInfo) -> IPv4Address | None:
672 """Return an IPv4 address suitable for pyatv."""
673 for address in info.parsed_addresses():
674 try:
675 return IPv4Address(address)
676 except AddressValueError:
677 continue
678 try:
679 return IPv4Address(self.address)
680 except AddressValueError:
681 return None
682
683 @property
684 def _stream_active(self) -> bool:
685 """Return whether Music Assistant is actively streaming to this device."""
686 active = bool((stream := getattr(self, "stream", None)) and stream.running)
687 if active:
688 self._stream_last_active = time.monotonic()
689 return active
690
691 @property
692 def _external_state_blocked(self) -> bool:
693 """
694 Return whether externally observed playback state must be ignored.
695
696 While Music Assistant streams to this device, the stream is the SOLE
697 authority on player state. The check extends a grace period past a
698 stream's end because a warm-to-cold fallback briefly tears the stream
699 down mid-playback: a Companion/MRP update slipping through that window
700 applies the device's view of our own dying session as an "external
701 source", freezing the UI on a stale snapshot.
702 """
703 if self._stream_active:
704 return True
705 return time.monotonic() - getattr(self, "_stream_last_active", 0.0) < 15.0
706
707 @property
708 def _mrp_endpoint(self) -> tuple[AsyncServiceInfo, Protocol] | None:
709 """Return the preferred MRP endpoint and transport protocol."""
710 if supports_mrp_service(self.mrp_discovery_info):
711 assert self.mrp_discovery_info is not None
712 return self.mrp_discovery_info, Protocol.MRP
713 if supports_mrp_tunnel(self.airplay_discovery_info):
714 assert self.airplay_discovery_info is not None
715 return self.airplay_discovery_info, Protocol.AirPlay
716 return None
717
718 @property
719 def _mrp_credentials_key(self) -> str:
720 """Return the credential key for the active MRP transport."""
721 endpoint = self._mrp_endpoint
722 if endpoint is not None and endpoint[1] == Protocol.MRP:
723 return CONF_NATIVE_MRP_CREDENTIALS
724 return CONF_MRP_CREDENTIALS
725
726 @property
727 def _mrp_credentials(self) -> str | None:
728 """Return credentials for the active MRP transport."""
729 credentials = self.get_setup_value(self._mrp_credentials_key)
730 return str(credentials) if credentials else None
731
732 @property
733 def _uses_transient_mrp(self) -> bool:
734 """Return whether playback monitoring uses transient AirPlay credentials."""
735 # Mirrors pyatv's device rules: Apple TVs only accept real (paired) HAP
736 # credentials on the MRP tunnel and answer a transient pair-setup by
737 # showing the on-screen AirPlay pairing dialog, so they must never take
738 # this path - not even while the Companion record is still undiscovered.
739 # HomePods (and tunnel-capable third-party receivers) accept the
740 # transient handshake silently.
741 if self._is_apple_tv_device:
742 return False
743 return supports_transient_mrp(self.airplay_discovery_info)
744
745 @property
746 def _is_apple_tv_device(self) -> bool:
747 """Return whether the underlying device identifies itself as an Apple TV."""
748 if self.airplay_discovery_info and (
749 model := get_decoded_property(self.airplay_discovery_info, "model")
750 ):
751 return model.startswith("AppleTV")
752 return "apple tv" in self.device_info.model.lower()
753
754 def _device_for_feature(self, feature: FeatureName) -> AppleTV | None:
755 """Return the preferred connected device facade for a feature."""
756 for device in (self._companion_device, self._mrp_device):
757 if device and self._feature_available(device, feature):
758 return device
759 return None
760
761 def _device_for_power_feature(self, feature: FeatureName) -> AppleTV | None:
762 """
763 Return a connected device facade that can genuinely serve a power command.
764
765 pyatv reports the power commands as available on every MRP connection, but a
766 transient tunnel - the only control channel current HomePod firmware offers -
767 cannot act on them, and derives its power state from ``logicalDeviceCount``,
768 which does not count AirPlay audio sessions. Trusting it would leave a playing
769 HomePod stranded as "off".
770
771 :param feature: The power feature to look for.
772 """
773 if self._companion_device and self._feature_available(self._companion_device, feature):
774 return self._companion_device
775 if (
776 self._mrp_device
777 and not self._uses_transient_mrp
778 and self._feature_available(self._mrp_device, feature)
779 ):
780 return self._mrp_device
781 return None
782
783 @staticmethod
784 def _feature_available(device: AppleTV, feature: FeatureName) -> bool:
785 """Return whether pyatv currently exposes a feature."""
786 return device.features.in_state(FeatureState.Available, feature)
787
788 async def _wake_for_playback(self) -> None:
789 """Wake the device before starting or resuming playback."""
790 if self.powered is True:
791 return
792 device = self._device_for_power_feature(FeatureName.TurnOn)
793 if device is None:
794 return
795 self._power_on_event.clear()
796 await self._run_control_command(device.power.turn_on(), "wake")
797 await self._wait_for_wake()
798
799 async def _wait_for_wake(self) -> None:
800 """Wait briefly for a pushed powered-on state."""
801 if self.powered is True:
802 return
803 try:
804 await asyncio.wait_for(self._power_on_event.wait(), _WAKE_TIMEOUT)
805 except TimeoutError:
806 self.logger.debug("No power-state confirmation received from %s", self.display_name)
807
808 async def _run_control_command(self, command: Awaitable[None], description: str) -> None:
809 """Run a pyatv command and expose failures as player command errors."""
810 try:
811 await command
812 except _COMMAND_ERRORS as err:
813 raise PlayerCommandFailed(
814 f"Unable to {description} {self.display_name}: {err}"
815 ) from err
816
817 async def _run_volume_command(self, command: Awaitable[None], description: str) -> None:
818 """Run a native volume command, tolerating a missing confirmation event."""
819 # pyatv waits (up to 5s) for a pushed volume confirmation after a Companion
820 # volume command. Apple TVs that pass volume through to an HDMI-CEC amplifier
821 # apply the change but never emit that event, so the call times out even
822 # though it succeeded. Treat the timeout as success and let the caller apply
823 # the requested level; genuine command failures still surface.
824 try:
825 await command
826 except TimeoutError:
827 self.logger.debug(
828 "No volume confirmation from %s; assuming the change was applied",
829 self.display_name,
830 )
831 except _COMMAND_ERRORS as err:
832 raise PlayerCommandFailed(
833 f"Unable to {description} {self.display_name}: {err}"
834 ) from err
835
836 async def _run_companion_pairing(
837 self, session: SetupSession, collected: dict[str, ConfigValueType]
838 ) -> None:
839 """
840 Offer optional Companion (remote control) pairing, when supported.
841
842 Shows a skippable choice; on "set up now" it drives the PIN pairing and adds
843 the resulting credentials to ``collected`` (replacing any stored ones).
844
845 :param session: The setup flow session used to interact with the user.
846 :param collected: The values collected so far; updated in place.
847 """
848 if not self.companion_pairing_supported:
849 return
850 if not await self._offer_optional_pairing(session, "companion_offer"):
851 return
852 errors: dict[str, str] | None = None
853 while True:
854 pairing = await self._begin_pyatv_pairing(
855 self.companion_discovery_info, Protocol.Companion
856 )
857 try:
858 values = await session.form(
859 [
860 ConfigEntry(
861 key=CONF_COMPANION_PAIRING_PIN,
862 type=ConfigEntryType.PAIRING_CODE,
863 required=True,
864 category="protocol_generic",
865 format=PAIRING_PIN_FORMAT,
866 )
867 ],
868 step_id="pair_companion",
869 errors=errors,
870 )
871 credentials = await self._finish_pyatv_pairing(
872 pairing, str(values[CONF_COMPANION_PAIRING_PIN])
873 )
874 except PlayerCommandFailed as err:
875 errors = {"base": err.translation_key or str(err)}
876 continue
877 finally:
878 await pairing.close()
879 collected[CONF_COMPANION_CREDENTIALS] = credentials
880 return
881
882 async def _run_mrp_pairing(
883 self, session: SetupSession, collected: dict[str, ConfigValueType]
884 ) -> None:
885 """
886 Offer optional MRP (playback monitoring) pairing, when supported.
887
888 :param session: The setup flow session used to interact with the user.
889 :param collected: The values collected so far; updated in place.
890 """
891 if not self.mrp_pairing_supported:
892 return
893 endpoint = self._mrp_endpoint
894 if endpoint is None:
895 return
896 discovery_info, protocol = endpoint
897 cred_key = self._mrp_credentials_key
898 if not await self._offer_optional_pairing(session, "mrp_offer"):
899 return
900 errors: dict[str, str] | None = None
901 while True:
902 pairing = await self._begin_pyatv_pairing(discovery_info, protocol)
903 try:
904 values = await session.form(
905 [
906 ConfigEntry(
907 key=CONF_MRP_PAIRING_PIN,
908 type=ConfigEntryType.PAIRING_CODE,
909 required=True,
910 category="protocol_generic",
911 format=PAIRING_PIN_FORMAT,
912 )
913 ],
914 step_id="pair_mrp",
915 errors=errors,
916 )
917 credentials = await self._finish_pyatv_pairing(
918 pairing, str(values[CONF_MRP_PAIRING_PIN])
919 )
920 except PlayerCommandFailed as err:
921 errors = {"base": err.translation_key or str(err)}
922 continue
923 finally:
924 await pairing.close()
925 collected[cred_key] = credentials
926 return
927
928 async def _begin_pyatv_pairing(
929 self, discovery_info: AsyncServiceInfo | None, protocol: Protocol
930 ) -> PairingHandler:
931 """
932 Build a pyatv config and begin a Companion/MRP pairing (the device shows its PIN).
933
934 A failure here cannot be recovered by re-prompting, so it aborts the flow; a
935 partially started session is torn down first.
936
937 :param discovery_info: The mDNS record of the service to pair.
938 :param protocol: The pyatv protocol to pair (Companion or the MRP transport).
939 """
940 pairing_requirement = (
941 PairingRequirement.Optional
942 if protocol == Protocol.MRP
943 else PairingRequirement.Mandatory
944 )
945 config = (
946 self._build_config(discovery_info, protocol, None, pairing_requirement)
947 if discovery_info is not None
948 else None
949 )
950 if config is None:
951 raise AbortFlow("pairing_failed")
952 pairing: PairingHandler | None = None
953 started = False
954 try:
955 pairing = await pyatv.pair(config, protocol, self.mass.loop, name="Music Assistant")
956 await pairing.begin()
957 started = True
958 except Exception as err:
959 # any failure starting the pairing (device unreachable, pyatv/system
960 # issue, ...) is unrecoverable here; abort with a clear reason rather
961 # than letting it surface as a generic internal error
962 self.logger.warning("Could not start Apple TV pairing: %s", err)
963 raise AbortFlow("pairing_failed") from err
964 finally:
965 if not started and pairing is not None:
966 await pairing.close()
967 assert pairing is not None # reached only when started, i.e. a live session
968 return pairing
969
970 async def _finish_pyatv_pairing(self, pairing: PairingHandler, pin: str) -> str:
971 """
972 Submit the PIN and return the credentials from a Companion/MRP pairing session.
973
974 :param pairing: The active pyatv pairing session.
975 :param pin: The PIN the user entered.
976 """
977 try:
978 pin_code = int(pin)
979 except (TypeError, ValueError) as err:
980 raise PlayerCommandFailed(
981 "Enter the numeric PIN shown on the device",
982 translation_key="invalid_pin",
983 translation_owner=self.translation_owner,
984 ) from err
985 try:
986 pairing.pin(pin_code)
987 await pairing.finish()
988 except (pyatv_exceptions.PairingError, *_CONNECTION_ERRORS) as err:
989 raise PlayerCommandFailed(
990 f"Unable to finish pairing for {self.display_name}: {err}"
991 ) from err
992 credentials = pairing.service.credentials
993 if not pairing.has_paired or not credentials:
994 raise PlayerCommandFailed(
995 "Pairing did not complete", translation_key="authentication_failed"
996 )
997 return str(credentials)
998
999 def _apply_initial_device_state(self, device: AppleTV, source: str) -> None:
1000 """Apply power and volume snapshots exposed after connection."""
1001 if self._feature_available(device, FeatureName.PowerState):
1002 self._handle_power_update(source, device.power.power_state)
1003 if self._feature_available(device, FeatureName.Volume):
1004 self._handle_volume_update(source, device.audio.volume)
1005
1006 def _handle_power_update(self, source: str, power_state: PowerState) -> None:
1007 """Apply a pushed pyatv power state."""
1008 if source == "mrp" and self._companion_device is not None:
1009 return
1010 if power_state == PowerState.On:
1011 self._attr_powered = True
1012 self._power_on_event.set()
1013 elif power_state == PowerState.Off:
1014 # A device streaming from Music Assistant is not powered off, whatever the
1015 # control channel claims: MRP derives the state from logicalDeviceCount,
1016 # which does not count AirPlay audio sessions, and a Companion SystemStatus
1017 # can briefly report sleep mid-stream. Acting on it would strand the player
1018 # as "off" and trip the auto-ungroup in the players controller.
1019 if self._stream_active:
1020 return
1021 self._attr_powered = False
1022 self._power_on_event.clear()
1023 self._attr_playback_state = PlaybackState.IDLE
1024 self._attr_active_source = None
1025 self._attr_current_media = None
1026 else:
1027 self._attr_powered = None
1028 self.update_state()
1029
1030 def _handle_volume_update(self, source: str, volume: float) -> None:
1031 """Apply a pushed pyatv volume level."""
1032 # MRP volume is ignored when Companion owns volume, and always on a transient
1033 # tunnel: that only exposes the speaker's own volume, which Music Assistant
1034 # never drives. Worse, a 0 from there latches a mute, and a muted player
1035 # skips the stream volume command entirely - so every later volume change
1036 # silently stops reaching the device.
1037 if source == "mrp" and (self._companion_device is not None or self._uses_transient_mrp):
1038 return
1039 # While Music Assistant streams, volume_set drives the stream volume and
1040 # deliberately leaves the native device volume alone. The two are separate
1041 # knobs on a different scale, so a report about the native one must not
1042 # overwrite (or persist) the level the user just set on the stream.
1043 if source != "command" and self._stream_active:
1044 return
1045 volume_level = max(0, min(100, round(volume)))
1046 if volume_level == 0:
1047 if self._volume_before_mute is None and self._attr_volume_level:
1048 self._volume_before_mute = self._attr_volume_level
1049 mute_changed = self._attr_volume_muted is not True
1050 self._attr_volume_muted = True
1051 if mute_changed:
1052 self.update_state()
1053 return
1054 mute_changed = self._attr_volume_muted is not False
1055 self._attr_volume_muted = False
1056 self._volume_before_mute = None
1057 self._update_native_volume(volume_level, state_changed=mute_changed)
1058
1059 def _update_native_volume(self, volume: int, *, state_changed: bool = False) -> None:
1060 """Update and persist a volume reported by device control."""
1061 volume = max(0, min(100, volume))
1062 if self._attr_volume_level == volume:
1063 if state_changed:
1064 self.update_state()
1065 return
1066 self._attr_volume_level = volume
1067 self.mass.config.set_raw_player_config_value(
1068 self.player_id,
1069 CONF_STORED_VOLUME,
1070 volume,
1071 )
1072 self.update_state()
1073
1074 def _clear_stored_credentials(self, credentials_key: str) -> None:
1075 """Clear credentials that the receiver rejected."""
1076 self._update_setup_data(credentials_key, None)
1077 self.update_state()
1078
1079 def _handle_playing_update(self, playing: Playing) -> None:
1080 """Apply external playback state received over the MRP tunnel."""
1081 if self._external_state_blocked:
1082 return
1083 app = self._mrp_device.metadata.app if self._mrp_device else None
1084 playback_state = {
1085 DeviceState.Playing: PlaybackState.PLAYING,
1086 DeviceState.Seeking: PlaybackState.PLAYING,
1087 DeviceState.Paused: PlaybackState.PAUSED,
1088 }.get(playing.device_state, PlaybackState.IDLE)
1089 # Loading only means "about to play" while playback is already going on
1090 # (buffering between tracks). HomePods can get stuck in a perpetual
1091 # Loading state carrying the cached metadata of a long-dead session, so
1092 # a Loading snapshot on a player that is not already playing must map
1093 # to idle (matching Home Assistant's apple_tv handling), not playing.
1094 if (
1095 playing.device_state == DeviceState.Loading
1096 and self._attr_playback_state == PlaybackState.PLAYING
1097 ):
1098 playback_state = PlaybackState.PLAYING
1099 # Many tvOS apps (e.g. Netflix) report Idle rather than Paused when
1100 # paused. While the same app stays the active source, keep it paused
1101 # instead of going idle so transport controls resume the app itself
1102 # rather than falling back to the Music Assistant queue.
1103 if (
1104 playback_state == PlaybackState.IDLE
1105 and app is not None
1106 and self._attr_active_source == app.identifier
1107 ):
1108 playback_state = PlaybackState.PAUSED
1109 self._attr_playback_state = playback_state
1110 if playback_state == PlaybackState.IDLE:
1111 self._attr_active_source = None
1112 self._attr_current_media = None
1113 self.update_state()
1114 return
1115
1116 source_id = app.identifier if app else "airplay_control"
1117 source_name = (app.name or app.identifier) if app else "AirPlay device"
1118 self._attr_active_source = source_id
1119 self._ensure_source(source_id, source_name)
1120 self._attr_elapsed_time = float(playing.position or 0)
1121 self._attr_elapsed_time_last_updated = time.time()
1122 image_url: str | None = None
1123 if (
1124 self._mrp_device
1125 and self._feature_available(self._mrp_device, FeatureName.Artwork)
1126 and isinstance(artwork_id := self._mrp_device.metadata.artwork_id, str)
1127 and artwork_id
1128 ):
1129 image_url = self._get_external_artwork_url(artwork_id)
1130 media_type = (
1131 MediaType.TRACK if playing.media_type == PyatvMediaType.Music else MediaType.UNKNOWN
1132 )
1133 self._attr_current_media = PlayerMedia(
1134 uri=playing.content_identifier or f"apple-device://{self.player_id}/{playing.hash}",
1135 media_type=media_type,
1136 title=playing.title or source_name,
1137 artist=playing.artist,
1138 album=playing.album,
1139 image_url=image_url,
1140 duration=playing.total_time,
1141 source_id=source_id,
1142 elapsed_time=playing.position,
1143 elapsed_time_last_updated=self._attr_elapsed_time_last_updated,
1144 )
1145 self.update_state()
1146
1147 def _ensure_source(self, source_id: str, source_name: str) -> None:
1148 """Add a passive source reported by MRP playback monitoring."""
1149 # Track external ids so the stream can reclaim the device state from a
1150 # leaked external snapshot (see AirPlayPlayer.set_state_from_stream).
1151 if not hasattr(self, "_external_source_ids"):
1152 self._external_source_ids: set[str] = set()
1153 self._external_source_ids.add(source_id)
1154 can_play_pause = bool(
1155 self._device_for_feature(FeatureName.Play)
1156 or self._device_for_feature(FeatureName.Pause)
1157 )
1158 can_next_previous = bool(
1159 self._device_for_feature(FeatureName.Next)
1160 or self._device_for_feature(FeatureName.Previous)
1161 )
1162 for source in self._attr_source_list:
1163 if source.id != source_id:
1164 continue
1165 source.name = source_name
1166 source.can_play_pause = can_play_pause
1167 source.can_next_previous = can_next_previous
1168 return
1169 self._attr_source_list.append(
1170 PlayerSource(
1171 id=source_id,
1172 name=source_name,
1173 passive=True,
1174 can_play_pause=can_play_pause,
1175 can_seek=False,
1176 can_next_previous=can_next_previous,
1177 )
1178 )
1179
1180 def _get_external_artwork_url(self, artwork_id: str) -> str:
1181 """Return the image-proxy URL for external MRP artwork."""
1182 image = MediaItemImage(
1183 type=ImageType.THUMB,
1184 path=f"{EXTERNAL_ARTWORK_PATH_PREFIX}/{self.player_id}/{artwork_id}",
1185 provider=self.provider_id,
1186 remotely_accessible=False,
1187 )
1188 return self.mass.metadata.get_image_url(image)
1189
1190 def _handle_connection_closed(
1191 self,
1192 source: str,
1193 device: AppleTV,
1194 exception: Exception | None = None,
1195 ) -> None:
1196 """Handle a pyatv connection closing."""
1197 companion_closed = source == "companion" and self._companion_device is device
1198 mrp_closed = source == "mrp" and self._mrp_device is device
1199 if companion_closed:
1200 self._companion_device = None
1201 self._companion_listener = None
1202 elif mrp_closed:
1203 self._mrp_device = None
1204 self._mrp_state_listener = None
1205 self._mrp_push_listener = None
1206 else:
1207 return
1208 # pyatv leaves the facade and the aiohttp session it created open on a drop.
1209 device.close()
1210 if mrp_closed:
1211 self._clear_external_state()
1212 if exception:
1213 self.logger.debug("Apple %s connection lost for %s: %s", source, self.name, exception)
1214 if companion_closed:
1215 self._notify_companion_state_change()
1216 if not self._disconnecting and not self._unloading:
1217 self._schedule_connection()
1218
1219 def _handle_push_error(self, device: AppleTV, exception: Exception) -> None:
1220 """Restart MRP playback monitoring after a push update error."""
1221 if self._mrp_device is not device:
1222 return
1223 self.logger.debug("MRP playback updates failed for %s: %s", self.name, exception)
1224 device.push_updater.stop()
1225 self._mrp_device = None
1226 self._mrp_state_listener = None
1227 self._mrp_push_listener = None
1228 device.close()
1229 self._clear_external_state()
1230 self._schedule_connection()
1231
1232 def _clear_external_state(self) -> None:
1233 """Drop the playback snapshot observed over a closed MRP connection."""
1234 if self._external_state_blocked or self._attr_active_source is None:
1235 return
1236 # Without a live connection the snapshot can no longer be updated, and the
1237 # last one is typically an app held at paused: leaving it in place keeps
1238 # transport commands aimed at that app instead of the Music Assistant queue.
1239 self.mark_external_source_ended()
1240 self.update_state()
1241
1242 def _notify_companion_state_change(self) -> None:
1243 """Notify a wired-up observer that the Companion connection state changed."""
1244 if self.on_companion_state_change is not None:
1245 self.on_companion_state_change()
1246
1247 @staticmethod
1248 def _service_signature(info: AsyncServiceInfo | None) -> tuple[object, ...] | None:
1249 """Return fields that require a pyatv reconnection when changed."""
1250 if info is None:
1251 return None
1252 # TXT keys are case-insensitive (RFC 6763); casefold them so a re-cased
1253 # key is never mistaken for a connection-relevant change.
1254 stable_properties = tuple(
1255 sorted(
1256 (key.casefold(), value)
1257 for key, value in info.decoded_properties.items()
1258 if key.casefold() not in _VOLATILE_DISCOVERY_KEYS
1259 )
1260 )
1261 return (
1262 info.name,
1263 info.port,
1264 tuple(info.addresses),
1265 stable_properties,
1266 )
1267
1268
1269class _AirPlayStateListener(DeviceListener, PowerListener, AudioListener):
1270 """Forward pyatv device, power, and volume events to a controlled player."""
1271
1272 def __init__(self, player: AirPlayControlPlayer, device: AppleTV, source: str) -> None:
1273 """Initialize a listener for one pyatv connection."""
1274 self._player = player
1275 self._device = device
1276 self._source = source
1277
1278 def connection_lost(self, exception: Exception) -> None:
1279 """Handle an unexpected pyatv disconnect."""
1280 self._player._handle_connection_closed(self._source, self._device, exception)
1281
1282 def connection_closed(self) -> None:
1283 """Handle a closed pyatv connection."""
1284 self._player._handle_connection_closed(self._source, self._device)
1285
1286 def powerstate_update(self, old_state: PowerState, new_state: PowerState) -> None:
1287 """Forward a power-state update."""
1288 self._player._handle_power_update(self._source, new_state)
1289
1290 def volume_update(self, old_level: float, new_level: float) -> None:
1291 """Forward a volume update."""
1292 self._player._handle_volume_update(self._source, new_level)
1293
1294 def volume_device_update(
1295 self,
1296 output_device: OutputDevice,
1297 old_level: float,
1298 new_level: float,
1299 ) -> None:
1300 """Ignore volume updates for secondary output devices."""
1301
1302 def outputdevices_update(
1303 self,
1304 old_devices: list[OutputDevice],
1305 new_devices: list[OutputDevice],
1306 ) -> None:
1307 """Ignore output-device membership updates."""
1308
1309
1310class _AirPlayPushListener(PushListener):
1311 """Forward MRP now-playing updates to a controlled player."""
1312
1313 def __init__(self, player: AirPlayControlPlayer, device: AppleTV) -> None:
1314 """Initialize an MRP push listener."""
1315 self._player = player
1316 self._device = device
1317
1318 def playstatus_update(self, updater: object, playstatus: Playing) -> None:
1319 """Forward an external playback update."""
1320 self._player._handle_playing_update(playstatus)
1321
1322 def playstatus_error(self, updater: object, exception: Exception) -> None:
1323 """Handle an MRP push update failure."""
1324 self._player._handle_push_error(self._device, exception)
1325