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