music-assistant-server

20.9 KBPY
player.py
20.9 KB510 lines • python
1"""Home Assistant Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from typing import TYPE_CHECKING, Any, cast
8
9from hass_client.exceptions import FailedCommand
10from music_assistant_models.enums import (
11    ImageType,
12    MediaType,
13    PlaybackState,
14    PlayerFeature,
15    PlayerType,
16)
17from music_assistant_models.media_items import MediaItemImage
18
19from music_assistant.constants import (
20    CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN,
21    CONF_ENTRY_HTTP_PROFILE_FORCED_2,
22    CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3,
23    HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES,
24    create_output_codec_config_entry,
25    create_sample_rates_config_entry,
26)
27from music_assistant.helpers.datetime import from_iso_string
28from music_assistant.helpers.tags import async_parse_tags
29from music_assistant.models.player import DeviceInfo, Player, PlayerMedia, PlayerSource
30from music_assistant.models.player_provider import PlayerProvider
31from music_assistant.providers.hass.constants import (
32    OFF_STATES,
33    UNAVAILABLE_STATES,
34    MediaPlayerEntityFeature,
35    StateMap,
36)
37
38from .constants import CONF_ENTRY_WARN_HASS_INTEGRATION, WARN_HASS_INTEGRATIONS
39from .helpers import ESPHomeSupportedAudioFormat
40
41if TYPE_CHECKING:
42    from hass_client import HomeAssistantClient
43    from hass_client.models import CompressedState
44    from hass_client.models import Entity as HassEntity
45    from hass_client.models import State as HassState
46    from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
47
48    from .provider import HomeAssistantPlayerProvider
49
50
51DEFAULT_PLAYER_CONFIG_ENTRIES = (CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3,)
52
53
54class HomeAssistantPlayer(Player):
55    """Home Assistant Player implementation."""
56
57    _attr_type = PlayerType.PLAYER
58
59    def __init__(
60        self,
61        provider: PlayerProvider,
62        hass: HomeAssistantClient,
63        player_id: str,
64        hass_state: HassState,
65        dev_info: dict[str, Any],
66        extra_player_data: dict[str, Any],
67        entity_registry: dict[str, HassEntity],
68    ) -> None:
69        """Initialize the Home Assistant Player."""
70        super().__init__(provider, player_id)
71        self.hass = hass
72        self.hass_state = hass_state
73        self._extra_data = extra_player_data
74        # Set base attributes from Home Assistant state
75        self._attr_available = hass_state["state"] not in UNAVAILABLE_STATES
76        self._attr_device_info = DeviceInfo.from_dict(dev_info)
77        self._attr_playback_state = StateMap.get(hass_state["state"], PlaybackState.IDLE)
78        # Work out supported features
79        self._attr_supported_features = set()
80        hass_supported_features = MediaPlayerEntityFeature(
81            hass_state["attributes"]["supported_features"]
82        )
83        if MediaPlayerEntityFeature.VOLUME_SET in hass_supported_features:
84            self._attr_supported_features.add(PlayerFeature.VOLUME_SET)
85        if MediaPlayerEntityFeature.VOLUME_MUTE in hass_supported_features:
86            self._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
87        if MediaPlayerEntityFeature.MEDIA_ANNOUNCE in hass_supported_features:
88            self._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
89        hass_domain = extra_player_data.get("hass_domain")
90        if hass_domain and MediaPlayerEntityFeature.GROUPING in hass_supported_features:
91            self._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
92            self._attr_can_group_with = {
93                x["entity_id"]
94                for x in entity_registry.values()
95                if x["entity_id"].startswith("media_player") and x["platform"] == hass_domain
96            }
97        if (
98            MediaPlayerEntityFeature.TURN_ON in hass_supported_features
99            and MediaPlayerEntityFeature.TURN_OFF in hass_supported_features
100        ):
101            self._attr_supported_features.add(PlayerFeature.POWER)
102            self._attr_powered = hass_state["state"] not in OFF_STATES
103
104        self.extra_data["hass_supported_features"] = hass_supported_features
105        self._hass_attributes: dict[str, Any] = {}
106
107        # Add External source to support next/prev commands when playing external content
108        self._attr_source_list.append(
109            PlayerSource(
110                id="External",
111                name="External Source",
112                passive=True,
113            )
114        )
115        # Set dynamic features (PAUSE, NEXT_PREVIOUS, SEEK) via shared helper
116        self._update_hass_features(hass_supported_features)
117
118        self._update_attributes(hass_state["attributes"])
119
120    @property
121    def requires_flow_mode(self) -> bool:
122        """Return if the player requires flow mode."""
123        # hass media players are a hot mess so play it safe and always use flow mode
124        return True
125
126    async def get_config_entries(
127        self,
128        action: str | None = None,
129        values: dict[str, ConfigValueType] | None = None,
130    ) -> list[ConfigEntry]:
131        """Return all (provider/player specific) Config Entries for the player."""
132        base_entries = [*DEFAULT_PLAYER_CONFIG_ENTRIES]
133        if self.extra_data.get("esphome_supported_audio_formats"):
134            # optimized config for new ESPHome mediaplayer
135            supported_sample_rates: list[int] = []
136            supported_bit_depths: list[int] = []
137            codec: str | None = None
138            supported_formats: list[ESPHomeSupportedAudioFormat] = self.extra_data[
139                "esphome_supported_audio_formats"
140            ]
141            # sort on purpose field, so we prefer the media pipeline
142            # but allows fallback to announcements pipeline if no media pipeline is available
143            supported_formats.sort(key=lambda x: x["purpose"])
144            for supported_format in supported_formats:
145                codec = supported_format["format"]
146                if supported_format["sample_rate"] not in supported_sample_rates:
147                    supported_sample_rates.append(supported_format["sample_rate"])
148                bit_depth = (supported_format["sample_bytes"] or 2) * 8
149                if bit_depth not in supported_bit_depths:
150                    supported_bit_depths.append(bit_depth)
151            if not supported_sample_rates or not supported_bit_depths:
152                # esphome device with no media pipeline configured
153                # simply use the default config of the media pipeline
154                supported_sample_rates = [48000]
155                supported_bit_depths = [16]
156
157            config_entries = [
158                *base_entries,
159                # New ESPHome mediaplayer (used in Voice PE) uses FLAC 48khz/16 bits
160                CONF_ENTRY_HTTP_PROFILE_FORCED_2,
161            ]
162
163            if codec is not None:
164                config_entries.append(create_output_codec_config_entry(True, codec))
165
166            config_entries.extend(
167                [
168                    CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN,
169                    create_sample_rates_config_entry(
170                        supported_sample_rates=supported_sample_rates,
171                        supported_bit_depths=supported_bit_depths,
172                        hidden=True,
173                    ),
174                    # although the Voice PE supports announcements,
175                    # it does not support volume for announcements
176                    *HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES,
177                ]
178            )
179
180            return config_entries
181
182        # add alert if player is a known player type that has a native provider in MA
183        if self.extra_data.get("hass_domain") in WARN_HASS_INTEGRATIONS:
184            base_entries = [CONF_ENTRY_WARN_HASS_INTEGRATION, *base_entries]
185
186        return base_entries
187
188    async def play(self) -> None:
189        """Handle PLAY command on the player."""
190        await self.hass.call_service(
191            domain="media_player",
192            service="media_play",
193            target={"entity_id": self.player_id},
194        )
195
196    async def pause(self) -> None:
197        """Handle PAUSE command on the player."""
198        await self.hass.call_service(
199            domain="media_player",
200            service="media_pause",
201            target={"entity_id": self.player_id},
202        )
203
204    async def stop(self) -> None:
205        """Send STOP command to player."""
206        try:
207            await self.hass.call_service(
208                domain="media_player",
209                service="media_stop",
210                target={"entity_id": self.player_id},
211            )
212        except FailedCommand as exc:
213            # some HA players do not support STOP
214            if "does not support" not in str(exc):
215                raise
216            if PlayerFeature.PAUSE in self.supported_features:
217                await self.pause()
218        finally:
219            self._attr_current_media = None
220            self.update_state()
221
222    async def volume_set(self, volume_level: int) -> None:
223        """Handle VOLUME_SET command on the player."""
224        await self.hass.call_service(
225            domain="media_player",
226            service="volume_set",
227            target={"entity_id": self.player_id},
228            service_data={"volume_level": volume_level / 100},
229        )
230
231    async def volume_mute(self, muted: bool) -> None:
232        """Handle VOLUME MUTE command on the player."""
233        await self.hass.call_service(
234            domain="media_player",
235            service="volume_mute",
236            target={"entity_id": self.player_id},
237            service_data={"is_volume_muted": muted},
238        )
239
240    async def power(self, powered: bool) -> None:
241        """Handle POWER command on the player."""
242        await self.hass.call_service(
243            domain="media_player",
244            service="turn_on" if powered else "turn_off",
245            target={"entity_id": self.player_id},
246        )
247
248    async def next_track(self) -> None:
249        """Handle NEXT_TRACK command on the player."""
250        await self.hass.call_service(
251            domain="media_player",
252            service="media_next_track",
253            target={"entity_id": self.player_id},
254        )
255
256    async def previous_track(self) -> None:
257        """Handle PREVIOUS_TRACK command on the player."""
258        await self.hass.call_service(
259            domain="media_player",
260            service="media_previous_track",
261            target={"entity_id": self.player_id},
262        )
263
264    async def play_media(self, media: PlayerMedia) -> None:
265        """Handle PLAY MEDIA on given player."""
266        extra_data: dict[str, Any] = {
267            # passing metadata to the player
268            # so far only supported by google cast, but maybe others can follow
269            "metadata": {
270                "title": media.title,
271                "artist": media.artist,
272                "metadataType": 3,
273                "album": media.album,
274                "albumName": media.album,
275                "images": [{"url": media.image_url}] if media.image_url else None,
276                "imageUrl": media.image_url,
277                "duration": media.duration,
278            },
279        }
280        if self.extra_data.get("hass_domain") == "esphome":
281            # tell esphome mediaproxy to bypass the proxy,
282            # as MA already delivers an optimized stream
283            extra_data["bypass_proxy"] = True
284
285        # stop the player if it is already playing
286        if self.playback_state == PlaybackState.PLAYING:
287            await self.stop()
288
289        await self.hass.call_service(
290            domain="media_player",
291            service="play_media",
292            target={"entity_id": self.player_id},
293            service_data={
294                "media_content_id": media.uri,
295                "media_content_type": "music",
296                "enqueue": "replace",
297                "extra": extra_data,
298            },
299        )
300
301        # Optimistically update state
302        self._attr_current_media = media
303        self._attr_elapsed_time = 0
304        self._attr_elapsed_time_last_updated = time.time()
305        self._attr_playback_state = PlaybackState.PLAYING
306        self.update_state()
307
308    async def play_announcement(
309        self, announcement: PlayerMedia, volume_level: int | None = None
310    ) -> None:
311        """Handle (provider native) playback of an announcement on given player."""
312        self.logger.info(
313            "Playing announcement %s on %s",
314            announcement.uri,
315            self.display_name,
316        )
317        if volume_level is not None:
318            self.logger.warning(
319                "Announcement volume level is not supported for player %s",
320                self.display_name,
321            )
322        await self.hass.call_service(
323            domain="media_player",
324            service="play_media",
325            service_data={
326                "media_content_id": announcement.uri,
327                "media_content_type": "music",
328                "announce": True,
329            },
330            target={"entity_id": self.player_id},
331        )
332        # Wait until the announcement is finished playing
333        # This is helpful for people who want to play announcements in a sequence
334        media_info = await async_parse_tags(announcement.uri, require_duration=True)
335        duration = media_info.duration or 5
336        await asyncio.sleep(duration)
337        self.logger.debug(
338            "Playing announcement on %s completed",
339            self.display_name,
340        )
341
342    async def set_members(
343        self,
344        player_ids_to_add: list[str] | None = None,
345        player_ids_to_remove: list[str] | None = None,
346    ) -> None:
347        """
348        Handle SET_MEMBERS command on the player.
349
350        Group or ungroup the given child player(s) to/from this player.
351        Will only be called if the PlayerFeature.SET_MEMBERS is supported.
352
353        :param player_ids_to_add: List of player_id's to add to the group.
354        :param player_ids_to_remove: List of player_id's to remove from the group.
355        """
356        for player_id_to_remove in player_ids_to_remove or []:
357            await self.hass.call_service(
358                domain="media_player",
359                service="unjoin",
360                target={"entity_id": player_id_to_remove},
361            )
362        if player_ids_to_add:
363            await self.hass.call_service(
364                domain="media_player",
365                service="join",
366                service_data={"group_members": player_ids_to_add},
367                target={"entity_id": self.player_id},
368            )
369
370    def update_from_compressed_state(self, state: CompressedState) -> None:
371        """Handle updating the player with updated info in a HA CompressedState."""
372        if "s" in state:
373            self._attr_playback_state = StateMap.get(state["s"], PlaybackState.IDLE)
374            self._attr_available = state["s"] not in UNAVAILABLE_STATES
375            if PlayerFeature.POWER in self.supported_features:
376                self._attr_powered = state["s"] not in OFF_STATES
377        if "a" in state:
378            self._update_attributes(state["a"])
379        self.update_state()
380
381    def _update_hass_features(self, hass_supported_features: MediaPlayerEntityFeature) -> None:
382        """Update player and External source features based on HA supported features."""
383        # Update player supported features for PAUSE and NEXT_PREVIOUS
384        if MediaPlayerEntityFeature.PAUSE in hass_supported_features:
385            self._attr_supported_features.add(PlayerFeature.PAUSE)
386        else:
387            self._attr_supported_features.discard(PlayerFeature.PAUSE)
388
389        has_next_prev = (
390            MediaPlayerEntityFeature.NEXT_TRACK in hass_supported_features
391            or MediaPlayerEntityFeature.PREVIOUS_TRACK in hass_supported_features
392        )
393        if has_next_prev:
394            self._attr_supported_features.add(PlayerFeature.NEXT_PREVIOUS)
395        else:
396            self._attr_supported_features.discard(PlayerFeature.NEXT_PREVIOUS)
397
398        # Update the External source capabilities
399        for source in self._attr_source_list:
400            if source.id == "External":
401                source.can_play_pause = MediaPlayerEntityFeature.PAUSE in hass_supported_features
402                source.can_next_previous = has_next_prev
403                source.can_seek = MediaPlayerEntityFeature.SEEK in hass_supported_features
404                break
405
406    def _update_attributes(self, attributes: dict[str, Any]) -> None:
407        """Update Player attributes from HA state attributes."""
408        self._hass_attributes.update(attributes)
409
410        # process optional attributes - these may not be present in all states
411        for key, value in attributes.items():
412            if key == "friendly_name":
413                self._attr_name = value
414            elif key == "media_position":
415                self._attr_elapsed_time = value
416            elif key == "media_position_updated_at":
417                self._attr_elapsed_time_last_updated = from_iso_string(value).timestamp()
418            elif key == "volume_level":
419                self._attr_volume_level = int(value * 100)
420            elif key == "is_volume_muted":
421                self._attr_volume_muted = value
422            elif key == "group_members":
423                group_members: list[str] = (
424                    [
425                        # ignore integrations that incorrectly set the group members attribute
426                        # (e.g. linkplay)
427                        x
428                        for x in value
429                        if x.startswith("media_player.")
430                    ]
431                    if value
432                    else []
433                )
434                if group_members and group_members[0] == self.player_id:
435                    # first in the list is the group leader
436                    self._attr_group_members = group_members
437                elif group_members and group_members[0] != self.player_id:
438                    # this player is not the group leader
439                    self._attr_group_members.clear()
440                else:
441                    self._attr_group_members.clear()
442            elif key == "supported_features":
443                # Update supported features dynamically via shared helper
444                hass_supported_features = MediaPlayerEntityFeature(value)
445                self.extra_data["hass_supported_features"] = hass_supported_features
446                self._update_hass_features(hass_supported_features)
447
448        # Check for external playback (not from Music Assistant).
449        # Without media_content_id we cannot reliably determine the source,
450        # so we later only react to state updates that include it.
451        media_content_id = self._hass_attributes.get("media_content_id", "")
452        is_ma_playback = media_content_id.startswith(self.mass.streams.base_url)
453
454        media_title = self._hass_attributes.get("media_title")
455
456        if media_content_id and is_ma_playback:
457            # MA playback - ensure active_source points to player_id for queue lookup.
458            # The actual current_media will be set by MA's queue controller.
459            self._attr_active_source = self.player_id
460        elif (
461            media_content_id
462            and media_title
463            and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
464        ):
465            # External playback detected - set current_media from HA attributes
466            ha_content_type = self._hass_attributes.get("media_content_type", "")
467            media_type = MediaType.RADIO if ha_content_type == "radio" else MediaType.UNKNOWN
468            current_media = PlayerMedia(
469                uri=media_content_id,
470                media_type=media_type,
471                title=media_title,
472                artist=self._hass_attributes.get("media_artist"),
473                album=self._hass_attributes.get("media_album_name"),
474                image_url=self._get_image_url(self._hass_attributes),
475                duration=int(self._hass_attributes.get("media_duration", 0) or 0) or None,
476            )
477            self._attr_current_media = current_media
478            self._attr_active_source = "External"
479
480        elif self.playback_state == PlaybackState.IDLE:
481            # Clear external media if it was set
482            if self._attr_active_source and self._attr_active_source not in (
483                self.player_id,
484                None,
485            ):
486                self._attr_current_media = None
487                self._attr_active_source = None
488
489    def _get_image_url(self, attributes: dict[str, Any]) -> str | None:
490        """Get the image URL from the attributes."""
491        if entity_picture := attributes.get("entity_picture"):
492            entity_picture = str(entity_picture)
493            if entity_picture.startswith("http"):
494                return entity_picture
495
496            # Access via provider -> hass_prov
497            prov = cast("HomeAssistantPlayerProvider", self.provider)
498
499            # Use proxy for internal HA images
500            # We create a MediaItemImage with the hass provider as source
501            # This will trigger resolve_image on the hass provider when requested
502            image = MediaItemImage(
503                type=ImageType.THUMB,
504                path=entity_picture,
505                provider=prov.hass_prov.instance_id,
506                remotely_accessible=False,
507            )
508            return self.mass.metadata.get_image_url(image)
509        return None
510