music-assistant-server

30 KBPY
player.py
30 KB733 lines • python
1"""AirPlay Player implementations."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from typing import TYPE_CHECKING, cast
8
9from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
10from music_assistant_models.enums import (
11    ConfigEntryType,
12    IdentifierType,
13    PlaybackState,
14    PlayerFeature,
15    PlayerType,
16)
17
18from music_assistant.constants import CONF_ENTRY_SYNC_ADJUST, create_sample_rates_config_entry
19from music_assistant.helpers.util import is_valid_mac_address
20from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
21
22from .constants import (
23    AIRPLAY_DISCOVERY_TYPE,
24    AIRPLAY_FLOW_PCM_FORMAT,
25    AIRPLAY_OUTPUT_BUFFER_DURATION_MS,
26    AIRPLAY_OUTPUT_BUFFER_MIN_DURATION_MS,
27    BASE_PLAYER_FEATURES,
28    BROKEN_AIRPLAY_WARN,
29    CACHE_CATEGORY_PREV_VOLUME,
30    CONF_ACTION_FINISH_PAIRING,
31    CONF_ACTION_RESET_PAIRING,
32    CONF_ACTION_START_PAIRING,
33    CONF_AIRPLAY_CREDENTIALS,
34    CONF_AIRPLAY_LATENCY,
35    CONF_AIRPLAY_PROTOCOL,
36    CONF_ALAC_ENCODE,
37    CONF_ENCRYPTION,
38    CONF_IGNORE_VOLUME,
39    CONF_PAIRING_PIN,
40    CONF_PASSWORD,
41    CONF_RAOP_CREDENTIALS,
42    FALLBACK_VOLUME,
43    LEGACY_PAIRING_BIT,
44    PIN_REQUIRED,
45    RAOP_DISCOVERY_TYPE,
46    StreamingProtocol,
47)
48from .helpers import (
49    get_primary_ip_address_from_zeroconf,
50    is_airplay2_preferred_model,
51    is_apple_device,
52    is_broken_airplay_model,
53    player_id_to_mac_address,
54)
55from .stream_session import AirPlayStreamSession
56
57if TYPE_CHECKING:
58    from zeroconf.asyncio import AsyncServiceInfo
59
60    from .pairing import AirPlayPairing
61    from .protocols._protocol import AirPlayProtocol
62    from .protocols.airplay2 import AirPlay2Stream
63    from .protocols.raop import RaopStream
64    from .provider import AirPlayProvider
65
66
67class AirPlayPlayer(Player):
68    """AirPlay Player implementation."""
69
70    def __init__(
71        self,
72        provider: AirPlayProvider,
73        player_id: str,
74        raop_discovery_info: AsyncServiceInfo | None,
75        airplay_discovery_info: AsyncServiceInfo | None,
76        address: str,
77        display_name: str,
78        manufacturer: str,
79        model: str,
80        initial_volume: int = FALLBACK_VOLUME,
81    ) -> None:
82        """Initialize AirPlayPlayer."""
83        self.raop_discovery_info = raop_discovery_info
84        self.airplay_discovery_info = airplay_discovery_info
85        super().__init__(provider, player_id)
86        self.address = address
87        self.stream: RaopStream | AirPlay2Stream | None = None
88        self.last_command_sent = 0.0
89        self._lock = asyncio.Lock()
90        self._active_pairing: AirPlayPairing | None = None
91        self._transitioning = False  # Set during stream replacement to ignore stale DACP messages
92        # Set (static) player attributes
93        self._attr_name = display_name
94        self._attr_available = True
95        mac_address = player_id_to_mac_address(player_id)
96        self._attr_device_info = DeviceInfo(
97            model=model,
98            manufacturer=manufacturer,
99        )
100        # Only add MAC address if it's valid (not 00:00:00:00:00:00)
101        if is_valid_mac_address(mac_address):
102            self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
103        self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
104        self._attr_volume_level = initial_volume
105        self._attr_can_group_with = {provider.instance_id}
106        self._attr_enabled_by_default = not is_broken_airplay_model(manufacturer, model)
107
108        # Set player type based on manufacturer:
109        # - Apple devices (HomePod, Apple TV, Mac) have native AirPlay support -> PLAYER
110        # - Non-Apple devices are generic AirPlay receivers -> PROTOCOL (wrapped in UniversalPlayer)
111        if is_apple_device(manufacturer):
112            self._attr_type = PlayerType.PLAYER
113        else:
114            self._attr_type = PlayerType.PROTOCOL
115
116    @property
117    def protocol(self) -> StreamingProtocol:
118        """Get the streaming protocol to use/prefer for this player."""
119        preferred_option = cast("int", self.config.get_value(CONF_AIRPLAY_PROTOCOL))
120        return self._get_protocol_for_config_value(preferred_option)
121
122    @property
123    def available(self) -> bool:
124        """Return if the player is currently available."""
125        if self._requires_pairing():
126            # check if we have credentials stored for the current protocol
127            creds_key = self._get_credentials_key(self.protocol)
128            if not self.config.get_value(creds_key):
129                return False
130        return super().available
131
132    @property
133    def requires_flow_mode(self) -> bool:
134        """Return if the player requires flow mode."""
135        return True
136
137    @property
138    def supported_features(self) -> set[PlayerFeature]:
139        """Return the supported features of this player."""
140        features = set(BASE_PLAYER_FEATURES)
141        if not (self.group_members or self.synced_to):
142            # we only support pause when the player is not synced,
143            # because we don't want to deal with the complexity of pausing a group of players
144            # so in this case stop will be used to pause the stream instead of pausing it,
145            # which is a common approach for AirPlay players
146            features.add(PlayerFeature.PAUSE)
147        return features
148
149    @property
150    def output_buffer_duration_ms(self) -> int:
151        """Get the configured output buffer duration in milliseconds."""
152        return cast(
153            "int",
154            self.config.get_value(CONF_AIRPLAY_LATENCY, AIRPLAY_OUTPUT_BUFFER_MIN_DURATION_MS),
155        )
156
157    async def get_config_entries(
158        self,
159        action: str | None = None,
160        values: dict[str, ConfigValueType] | None = None,
161    ) -> list[ConfigEntry]:
162        """Return all (provider/player specific) Config Entries for the given player (if any)."""
163        base_entries: list[ConfigEntry] = []
164        require_pairing = self._requires_pairing()
165
166        # Handle pairing actions
167        if action and require_pairing:
168            await self._handle_pairing_action(action=action, values=values)
169
170        # Add pairing config entries for Apple TV and macOS devices
171        if require_pairing:
172            base_entries = [*self._get_pairing_config_entries(values)]
173
174        # Regular AirPlay config entries
175        base_entries += [
176            ConfigEntry(
177                key=CONF_AIRPLAY_PROTOCOL,
178                type=ConfigEntryType.INTEGER,
179                required=False,
180                label="AirPlay protocol version to use for streaming",
181                description="AirPlay version 1 protocol uses RAOP.\n"
182                "AirPlay version 2 is an extension of RAOP.\n"
183                "Some newer devices do not fully support RAOP and "
184                "will only work with AirPlay version 2, "
185                "while older devices may only support RAOP.\n\n"
186                "In most cases the default automatic selection will work fine.",
187                options=[
188                    ConfigValueOption("Automatically select", 0),
189                    ConfigValueOption("Prefer AirPlay 1 (RAOP)", StreamingProtocol.RAOP.value),
190                    ConfigValueOption("Prefer AirPlay 2", StreamingProtocol.AIRPLAY2.value),
191                ],
192                default_value=0,
193                category="protocol_generic",
194            ),
195            ConfigEntry(
196                key=CONF_ENCRYPTION,
197                type=ConfigEntryType.BOOLEAN,
198                default_value=True,
199                label="Enable encryption",
200                description="Enable encrypted communication with the player, "
201                "some (3rd party) players require this to be disabled.",
202                depends_on=CONF_AIRPLAY_PROTOCOL,
203                depends_on_value=StreamingProtocol.RAOP.value,
204                hidden=self.protocol != StreamingProtocol.RAOP,
205                category="protocol_generic",
206                advanced=True,
207            ),
208            ConfigEntry(
209                key=CONF_ALAC_ENCODE,
210                type=ConfigEntryType.BOOLEAN,
211                default_value=True,
212                label="Enable compression",
213                description="Save some network bandwidth by sending the audio as "
214                "(lossless) ALAC at the cost of a bit of CPU.",
215                depends_on=CONF_AIRPLAY_PROTOCOL,
216                depends_on_value=StreamingProtocol.RAOP.value,
217                hidden=self.protocol != StreamingProtocol.RAOP,
218                category="protocol_generic",
219                advanced=True,
220            ),
221            CONF_ENTRY_SYNC_ADJUST,
222            ConfigEntry(
223                key=CONF_PASSWORD,
224                type=ConfigEntryType.SECURE_STRING,
225                default_value=None,
226                required=False,
227                label="Device password",
228                description="Some devices require a password to connect/play.",
229                depends_on=CONF_AIRPLAY_PROTOCOL,
230                depends_on_value=StreamingProtocol.RAOP.value,
231                hidden=self.protocol != StreamingProtocol.RAOP,
232                category="protocol_generic",
233                advanced=True,
234            ),
235            # airplay has fixed sample rate/bit depth so make this config entry static and hidden
236            create_sample_rates_config_entry(
237                supported_sample_rates=[44100], supported_bit_depths=[16], hidden=True
238            ),
239            ConfigEntry(
240                key=CONF_AIRPLAY_LATENCY,
241                type=ConfigEntryType.INTEGER,
242                default_value=AIRPLAY_OUTPUT_BUFFER_MIN_DURATION_MS,
243                range=(AIRPLAY_OUTPUT_BUFFER_MIN_DURATION_MS, AIRPLAY_OUTPUT_BUFFER_DURATION_MS),
244                label="Milliseconds of data to buffer",
245                description=(
246                    "The number of milliseconds of data to buffer\n"
247                    "NOTE: This adds to the latency experienced for commencement "
248                    "of playback. \n"
249                    "Try increasing value if playback is unreliable."
250                ),
251                category="protocol_generic",
252                depends_on=CONF_AIRPLAY_PROTOCOL,
253                depends_on_value=StreamingProtocol.AIRPLAY2.value,
254                hidden=self.protocol != StreamingProtocol.AIRPLAY2,
255                advanced=True,
256            ),
257        ]
258
259        if is_broken_airplay_model(self.device_info.manufacturer, self.device_info.model):
260            base_entries.insert(-1, BROKEN_AIRPLAY_WARN)
261
262        return base_entries
263
264    def _get_flags(self) -> int:
265        # Flags are either present via "sf" or "flags. Taken from pyatv.protocols.airplay.utils"
266        if self.airplay_discovery_info:
267            properties = self.airplay_discovery_info.properties
268        elif self.raop_discovery_info:
269            properties = self.raop_discovery_info.properties
270        else:
271            return 0
272
273        flags = properties.get(b"sf") or properties.get(b"flags") or "0x0"
274        return int(flags, 16)
275
276    def _requires_pairing(self) -> bool:
277        """Check if this device requires pairing.
278
279        Adapted from pyatv.protocols.airplay.utils.get_pairing_requirement.
280        """
281        return bool(self._get_flags() & (LEGACY_PAIRING_BIT | PIN_REQUIRED))
282
283    def _get_credentials_key(self, protocol: StreamingProtocol) -> str:
284        """Get the config key for credentials for given protocol."""
285        if protocol == StreamingProtocol.RAOP:
286            return CONF_RAOP_CREDENTIALS
287        return CONF_AIRPLAY_CREDENTIALS
288
289    def _get_protocol_for_config_value(self, config_option: int) -> StreamingProtocol:
290        if config_option == StreamingProtocol.AIRPLAY2 and self.airplay_discovery_info:
291            return StreamingProtocol.AIRPLAY2
292        if config_option == StreamingProtocol.RAOP and self.raop_discovery_info:
293            return StreamingProtocol.RAOP
294        # automatic selection
295        if self.airplay_discovery_info and is_airplay2_preferred_model(
296            self.device_info.manufacturer, self.device_info.model
297        ):
298            return StreamingProtocol.AIRPLAY2
299        return StreamingProtocol.RAOP
300
301    def _get_pairing_config_entries(
302        self, values: dict[str, ConfigValueType] | None
303    ) -> list[ConfigEntry]:
304        """
305        Return pairing config entries for Apple TV and macOS devices.
306
307        Uses native pairing for both AirPlay 2 (HAP) and RAOP protocols.
308        """
309        entries: list[ConfigEntry] = []
310
311        # Determine protocol name for UI
312        conf_protocol: int = 0
313        if values and (val := values.get(CONF_AIRPLAY_PROTOCOL)):
314            conf_protocol = cast("int", val)
315        else:
316            conf_protocol = cast("int", self.config.get_value(CONF_AIRPLAY_PROTOCOL, 0) or 0)
317        protocol = self._get_protocol_for_config_value(conf_protocol)
318        protocol_name = "RAOP" if protocol == StreamingProtocol.RAOP else "AirPlay"
319        protocol_key = (
320            CONF_RAOP_CREDENTIALS
321            if protocol == StreamingProtocol.RAOP
322            else CONF_AIRPLAY_CREDENTIALS
323        )
324        has_creds_for_current_protocol = (
325            values.get(protocol_key) if values else self.config.get_value(protocol_key)
326        )
327
328        if not has_creds_for_current_protocol:
329            # If pairing was started, show PIN entry
330            if self._active_pairing and self._active_pairing.is_pairing:
331                entries.append(
332                    ConfigEntry(
333                        key=CONF_PAIRING_PIN,
334                        type=ConfigEntryType.STRING,
335                        label="Enter the 4-digit PIN shown on the device",
336                        required=True,
337                        category="protocol_generic",
338                    )
339                )
340                entries.append(
341                    ConfigEntry(
342                        key=CONF_ACTION_FINISH_PAIRING,
343                        type=ConfigEntryType.ACTION,
344                        label=f"Complete {protocol_name} pairing with the PIN",
345                        action=CONF_ACTION_FINISH_PAIRING,
346                        category="protocol_generic",
347                    )
348                )
349            else:
350                # Show pairing instructions and start button
351                entries.append(
352                    ConfigEntry(
353                        key="pairing_instructions",
354                        type=ConfigEntryType.LABEL,
355                        label=(
356                            f"This device requires {protocol_name} pairing before it can be used. "
357                            "Click the button below to start the pairing process."
358                        ),
359                        category="protocol_generic",
360                    )
361                )
362                entries.append(
363                    ConfigEntry(
364                        key=CONF_ACTION_START_PAIRING,
365                        type=ConfigEntryType.ACTION,
366                        label=f"Start {protocol_name} pairing",
367                        action=CONF_ACTION_START_PAIRING,
368                        category="protocol_generic",
369                    )
370                )
371        else:
372            # Show paired status
373            entries.append(
374                ConfigEntry(
375                    key="pairing_status",
376                    type=ConfigEntryType.LABEL,
377                    label=f"Device is paired ({protocol_name}) and ready to use.",
378                    category="protocol_generic",
379                )
380            )
381            # Add reset pairing button
382            entries.append(
383                ConfigEntry(
384                    key=CONF_ACTION_RESET_PAIRING,
385                    type=ConfigEntryType.ACTION,
386                    label=f"Reset {protocol_name} pairing",
387                    action=CONF_ACTION_RESET_PAIRING,
388                    category="protocol_generic",
389                )
390            )
391
392        # Store credentials (hidden from UI)
393        for protocol in (StreamingProtocol.RAOP, StreamingProtocol.AIRPLAY2):
394            conf_key = self._get_credentials_key(protocol)
395            entries.append(
396                ConfigEntry(
397                    key=conf_key,
398                    type=ConfigEntryType.SECURE_STRING,
399                    label=conf_key,
400                    default_value=None,
401                    value=values.get(conf_key) if values else None,
402                    required=False,
403                    hidden=True,
404                    category="protocol_generic",
405                )
406            )
407        return entries
408
409    async def _handle_pairing_action(
410        self, action: str, values: dict[str, ConfigValueType] | None
411    ) -> None:
412        """
413        Handle pairing actions.
414
415        Uses native pairing for both AirPlay 2 (HAP) and RAOP protocols.
416        Both produce credentials compatible with cliap2/cliraop respectively.
417        """
418        conf_protocol: int = 0
419        if values and (val := values.get(CONF_AIRPLAY_PROTOCOL)):
420            conf_protocol = cast("int", val)
421        else:
422            conf_protocol = cast("int", self.config.get_value(CONF_AIRPLAY_PROTOCOL, 0) or 0)
423        protocol = self._get_protocol_for_config_value(conf_protocol)
424        protocol_name = "RAOP" if protocol == StreamingProtocol.RAOP else "AirPlay"
425
426        if action == CONF_ACTION_START_PAIRING:
427            if self._active_pairing and self._active_pairing.is_pairing:
428                self.logger.warning("Pairing process already in progress for %s", self.display_name)
429                return
430
431            self.logger.info("Starting %s pairing for %s", protocol_name, self.display_name)
432
433            from .pairing import AirPlayPairing  # noqa: PLC0415
434
435            # Determine port based on protocol
436            # Note: For Apple devices, pairing always happens on the AirPlay port (7000)
437            # even when streaming will use RAOP. The RAOP port (5000) is only for streaming.
438            port: int | None = None
439            if self.airplay_discovery_info:
440                port = self.airplay_discovery_info.port or 7000
441            elif self.raop_discovery_info:
442                # Fallback for devices without AirPlay service
443                port = self.raop_discovery_info.port or 5000
444            # Get the DACP ID from the provider - must match what cliap2 uses
445            provider = cast("AirPlayProvider", self.provider)
446            device_id = provider.dacp_id
447
448            self._active_pairing = AirPlayPairing(
449                address=self.address,
450                name=self.display_name,
451                protocol=protocol,
452                logger=self.logger,
453                port=port,
454                device_id=device_id,
455            )
456            await self._active_pairing.start_pairing()
457
458        elif action == CONF_ACTION_FINISH_PAIRING:
459            if not values:
460                return
461
462            pin = values.get(CONF_PAIRING_PIN)
463            if not pin:
464                self.logger.warning("No PIN provided for pairing")
465                return
466
467            if not self._active_pairing:
468                self.logger.warning("No active pairing session for %s", self.display_name)
469                return
470
471            credentials = await self._active_pairing.finish_pairing(pin=str(pin))
472            self._active_pairing = None
473
474            # Store credentials with the protocol-specific key
475            cred_key = self._get_credentials_key(protocol)
476            values[cred_key] = credentials
477
478            self.logger.info("Finished %s pairing for %s", protocol_name, self.display_name)
479
480        elif action == CONF_ACTION_RESET_PAIRING:
481            cred_key = self._get_credentials_key(protocol)
482            self.logger.info("Resetting %s pairing for %s", protocol_name, self.display_name)
483            if values is not None:
484                values[cred_key] = None
485
486    async def stop(self) -> None:
487        """Send STOP command to player."""
488        if self.stream and self.stream.session:
489            # forward stop to the entire stream session
490            await self.stream.session.stop()
491        self._attr_current_media = None
492        self.update_state()
493
494    async def play(self) -> None:
495        """Send PLAY (unpause) command to player."""
496        async with self._lock:
497            if self.stream and self.stream.running:
498                await self.stream.send_cli_command("ACTION=PLAY")
499
500    async def pause(self) -> None:
501        """Send PAUSE command to player."""
502        if self.group_members:
503            # pause is not supported while synced, use stop instead
504            self.logger.debug("Player is synced, using STOP instead of PAUSE")
505            await self.stop()
506            return
507
508        async with self._lock:
509            if not self.stream or not self.stream.running:
510                return
511            await self.stream.send_cli_command("ACTION=PAUSE")
512
513    async def play_media(self, media: PlayerMedia) -> None:
514        """Handle PLAY MEDIA on given player."""
515        if self.synced_to:
516            # this should not happen, but guard anyways
517            raise RuntimeError("Player is synced")
518        self._attr_current_media = media
519
520        # Always stop any existing stream
521        if self.stream and self.stream.running and self.stream.session:
522            # Set transitioning flag to ignore stale DACP messages (like prevent-playback)
523            self._transitioning = True
524            await self.stream.session.stop()
525            self.stream = None
526
527        # select audio source
528        audio_source = self.mass.streams.get_stream(media, AIRPLAY_FLOW_PCM_FORMAT, self.player_id)
529
530        # setup StreamSession for player (and its sync childs if any)
531        sync_clients = self._get_sync_clients()
532        provider = cast("AirPlayProvider", self.provider)
533        stream_session = AirPlayStreamSession(provider, sync_clients, AIRPLAY_FLOW_PCM_FORMAT)
534        await stream_session.start(audio_source)
535        self._attr_elapsed_time = time.time() - stream_session.start_time
536        self._attr_elapsed_time_last_updated = time.time()
537        self._transitioning = False
538
539    async def volume_set(self, volume_level: int) -> None:
540        """Send VOLUME_SET command to given player."""
541        if self.stream and self.stream.running:
542            await self.stream.send_cli_command(f"VOLUME={volume_level}")
543        self._attr_volume_level = volume_level
544        self.update_state()
545        # store last state in cache
546        await self.mass.cache.set(
547            key=self.player_id,
548            data=volume_level,
549            provider=self.provider.instance_id,
550            category=CACHE_CATEGORY_PREV_VOLUME,
551        )
552
553    async def set_members(
554        self,
555        player_ids_to_add: list[str] | None = None,
556        player_ids_to_remove: list[str] | None = None,
557    ) -> None:
558        """Handle SET_MEMBERS command on the player."""
559        if self.synced_to:
560            # this should not happen, but guard anyways
561            raise RuntimeError("Player is synced, cannot set members")
562        if not player_ids_to_add and not player_ids_to_remove:
563            # nothing to do
564            return
565
566        stream_session = (
567            self.stream.session
568            if self.stream and self.stream.running and self.stream.session
569            else None
570        )
571        # handle removals first
572        if player_ids_to_remove:
573            if self.player_id in player_ids_to_remove:
574                # dissolve the entire sync group
575                if stream_session:
576                    # stop the stream session if it is running
577                    await stream_session.stop()
578                self._attr_group_members = []
579                self.update_state()
580                return
581
582            for child_player in self._get_sync_clients():
583                if child_player.player_id in player_ids_to_remove:
584                    if stream_session:
585                        await stream_session.remove_client(child_player)
586                    if child_player.player_id in self._attr_group_members:
587                        self._attr_group_members.remove(child_player.player_id)
588
589            # If group leader is left alone after removals, clear the group_members list
590            if (
591                self._attr_group_members
592                and len(self._attr_group_members) == 1
593                and self.player_id in self._attr_group_members
594            ):
595                self._attr_group_members = []
596
597        # handle additions
598        for player_id in player_ids_to_add or []:
599            if player_id == self.player_id or player_id in self.group_members:
600                # nothing to do: player is already part of the group
601                continue
602            child_player_to_add: AirPlayPlayer | None = cast(
603                "AirPlayPlayer | None", self.mass.players.get_player(player_id)
604            )
605            if not child_player_to_add:
606                # should not happen, but guard against it
607                continue
608            if child_player_to_add.synced_to and child_player_to_add.synced_to != self.player_id:
609                raise RuntimeError("Player is already synced to another player")
610
611            # ensure the child does not have an existing stream session active
612            if child_player_to_add := cast(
613                "AirPlayPlayer | None", self.mass.players.get_player(player_id)
614            ):
615                if (
616                    child_player_to_add.playback_state == PlaybackState.PAUSED
617                    and child_player_to_add.stream
618                ):
619                    # Stop the paused stream to avoid a deadlock situation
620                    await child_player_to_add.stream.stop()
621                if (
622                    child_player_to_add.stream
623                    and child_player_to_add.stream.running
624                    and child_player_to_add.stream.session
625                    and child_player_to_add.stream.session != stream_session
626                ):
627                    await child_player_to_add.stream.session.remove_client(child_player_to_add)
628
629            # add new child to the existing stream (RAOP or AirPlay2) session (if any)
630            self._attr_group_members.append(player_id)
631            if stream_session and child_player_to_add is not None:
632                await stream_session.add_client(child_player_to_add)
633
634        # Ensure group leader includes itself in group_members when it has members
635        # This is required for the synced_to property to work correctly
636        if self._attr_group_members and self.player_id not in self._attr_group_members:
637            self._attr_group_members.insert(0, self.player_id)
638
639        # always update the state after modifying group members
640        self.update_state()
641
642    def _on_player_media_updated(self) -> None:
643        """Handle callback when the current media of the player is updated."""
644        if not self.stream or not self.stream.running or not self.stream.session:
645            return
646        metadata = self.state.current_media
647        if not metadata:
648            return
649        progress = int(metadata.corrected_elapsed_time or 0)
650        self.mass.create_task(self.stream.send_metadata(progress, metadata))
651
652    def update_volume_from_device(self, volume: int) -> None:
653        """Update volume from device feedback."""
654        ignore_volume_report = (
655            self.config.get_value(CONF_IGNORE_VOLUME)
656            or self.device_info.manufacturer.lower() == "apple"
657        )
658
659        if ignore_volume_report:
660            return
661
662        cur_volume = self.volume_level or 0
663        if abs(cur_volume - volume) > 3 or (time.time() - self.last_command_sent) > 3:
664            self.mass.create_task(self.volume_set(volume))
665        else:
666            self._attr_volume_level = volume
667            self.update_state()
668
669    def set_discovery_info(self, discovery_info: AsyncServiceInfo, display_name: str) -> None:
670        """Set/update the discovery info for the player."""
671        self._attr_name = display_name
672        if discovery_info.type == AIRPLAY_DISCOVERY_TYPE:
673            self.airplay_discovery_info = discovery_info
674        elif discovery_info.type == RAOP_DISCOVERY_TYPE:
675            self.raop_discovery_info = discovery_info
676        else:  # guard
677            return
678        cur_address = self.address
679        new_address = get_primary_ip_address_from_zeroconf(discovery_info)
680        if new_address is None:
681            # should always be set, but guard against None
682            return
683        if cur_address != new_address:
684            self.logger.debug("Address updated from %s to %s", cur_address, new_address)
685            self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, new_address)
686            self.address = new_address
687        self.update_state()
688
689    def set_state_from_stream(
690        self,
691        state: PlaybackState | None = None,
692        elapsed_time: float | None = None,
693        stream: AirPlayProtocol | None = None,
694    ) -> None:
695        """Set the playback state from stream (RAOP or AirPlay2).
696
697        :param state: New playback state (or None to keep current).
698        :param elapsed_time: New elapsed time (or None to keep current).
699        :param stream: The stream instance sending this update (for validation).
700        """
701        # Ignore state updates from old/stale streams
702        if stream is not None and stream != self.stream:
703            return
704        if state is not None:
705            self._attr_playback_state = state
706        if elapsed_time is not None:
707            self._attr_elapsed_time = elapsed_time
708            self._attr_elapsed_time_last_updated = time.time()
709        self.update_state()
710
711    async def on_unload(self) -> None:
712        """Handle logic when the player is unloaded from the Player controller."""
713        await super().on_unload()
714        if self.stream:
715            # stop the stream session if it is running
716            if self.stream.running and self.stream.session:
717                self.mass.create_task(self.stream.session.stop())
718            self.stream = None
719        if self._active_pairing:
720            await self._active_pairing.close()
721            self._active_pairing = None
722
723    def _get_sync_clients(self) -> list[AirPlayPlayer]:
724        """Get all sync clients for a player."""
725        sync_clients: list[AirPlayPlayer] = []
726        # we need to return the player itself too
727        group_child_ids = {self.player_id}
728        group_child_ids.update(self.group_members)
729        for child_id in group_child_ids:
730            if client := cast("AirPlayPlayer | None", self.mass.players.get_player(child_id)):
731                sync_clients.append(client)
732        return sync_clients
733