From ec9c4766037e47852a9a1b0141d41ad8eb1f8a1b Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 21 Nov 2024 16:16:25 +0100 Subject: [PATCH] Fix: announcements on HA players --- music_assistant/controllers/players.py | 13 +- .../providers/hass_players/__init__.py | 123 +++++++++++------- 2 files changed, 77 insertions(+), 59 deletions(-) diff --git a/music_assistant/controllers/players.py b/music_assistant/controllers/players.py index 9e6edfe6..669077a5 100644 --- a/music_assistant/controllers/players.py +++ b/music_assistant/controllers/players.py @@ -46,7 +46,7 @@ from music_assistant.constants import ( from music_assistant.helpers.api import api_command from music_assistant.helpers.tags import parse_tags from music_assistant.helpers.throttle_retry import Throttler -from music_assistant.helpers.util import TaskManager, get_changed_values +from music_assistant.helpers.util import TaskManager, get_changed_values, lock from music_assistant.models.core_controller import CoreController from music_assistant.models.player_provider import PlayerProvider @@ -214,9 +214,6 @@ class PlayerController(CoreController): - player_id: player_id of the player to handle the command. """ player = self._get_player_with_redirect(player_id) - if player.announcement_in_progress: - self.logger.warning("Ignore command: An announcement is in progress") - return if PlayerFeature.PAUSE not in player.supported_features: # if player does not support pause, we need to send stop self.logger.info( @@ -526,6 +523,7 @@ class PlayerController(CoreController): await player_provider.cmd_volume_mute(player_id, muted) @api_command("players/cmd/play_announcement") + @lock async def play_announcement( self, player_id: str, @@ -537,10 +535,6 @@ class PlayerController(CoreController): player = self.get(player_id, True) if not url.startswith("http"): raise PlayerCommandFailed("Only URLs are supported for announcements") - if player.announcement_in_progress: - raise PlayerCommandFailed( - f"An announcement is already in progress to player {player.display_name}" - ) try: # mark announcement_in_progress on player player.announcement_in_progress = True @@ -589,7 +583,8 @@ class PlayerController(CoreController): # handle native announce support if native_announce_support: if prov := self.mass.get_provider(player.provider): - await prov.play_announcement(player_id, announcement, volume_level) + announcement_volume = self.get_announcement_volume(player_id, volume_level) + await prov.play_announcement(player_id, announcement, announcement_volume) return # use fallback/default implementation await self._play_announcement(player, announcement, volume_level) diff --git a/music_assistant/providers/hass_players/__init__.py b/music_assistant/providers/hass_players/__init__.py index 7771a7e4..01d4c820 100644 --- a/music_assistant/providers/hass_players/__init__.py +++ b/music_assistant/providers/hass_players/__init__.py @@ -7,6 +7,7 @@ Requires the Home Assistant Plugin. from __future__ import annotations +import asyncio import time from enum import IntFlag from typing import TYPE_CHECKING, Any @@ -26,6 +27,7 @@ from music_assistant.constants import ( CONF_ENTRY_HTTP_PROFILE, ) from music_assistant.helpers.datetime import from_iso_string +from music_assistant.helpers.tags import parse_tags from music_assistant.models.player_provider import PlayerProvider from music_assistant.providers.hass import DOMAIN as HASS_DOMAIN @@ -93,6 +95,7 @@ PLAYER_CONFIG_ENTRIES = ( CONF_ENTRY_ENFORCE_MP3_DEFAULT_ENABLED, CONF_ENTRY_HTTP_PROFILE, CONF_ENTRY_ENABLE_ICY_METADATA, + CONF_ENTRY_FLOW_MODE_ENFORCED, ) @@ -103,7 +106,7 @@ async def _get_hass_media_players( for state in await hass_prov.hass.get_states(): if not state["entity_id"].startswith("media_player"): continue - if "mass_player_id" in state["attributes"]: + if "mass_player_type" in state["attributes"]: # filter out mass players continue if "friendly_name" not in state["attributes"]: @@ -115,16 +118,6 @@ async def _get_hass_media_players( yield state -async def _get_hass_media_player( - hass_prov: HomeAssistantProvider, entity_id: str -) -> HassState | None: - """Return Hass state object for a single media_player entity.""" - for state in await hass_prov.hass.get_states(): - if state["entity_id"] == entity_id: - return state - return None - - async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: @@ -202,16 +195,8 @@ class HomeAssistantPlayers(PlayerProvider): player_id: str, ) -> tuple[ConfigEntry, ...]: """Return all (provider/player specific) Config Entries for the given player (if any).""" - entries = await super().get_player_config_entries(player_id) - entries = entries + PLAYER_CONFIG_ENTRIES - if hass_state := await _get_hass_media_player(self.hass_prov, player_id): - hass_supported_features = MediaPlayerEntityFeature( - hass_state["attributes"]["supported_features"] - ) - if MediaPlayerEntityFeature.MEDIA_ENQUEUE not in hass_supported_features: - entries += (CONF_ENTRY_FLOW_MODE_ENFORCED,) - - return entries + base_entries = await super().get_player_config_entries(player_id) + return base_entries + PLAYER_CONFIG_ENTRIES async def cmd_stop(self, player_id: str) -> None: """Send STOP command to given player. @@ -281,20 +266,39 @@ class HomeAssistantPlayers(PlayerProvider): player.elapsed_time = 0 player.elapsed_time_last_updated = time.time() - async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: - """Handle enqueuing of the next queue item on the player.""" - if self.mass.config.get_raw_player_config_value(player_id, CONF_ENFORCE_MP3, True): - media.uri = media.uri.replace(".flac", ".mp3") + async def play_announcement( + self, player_id: str, announcement: PlayerMedia, volume_level: int | None = None + ) -> None: + """Handle (provider native) playback of an announcement on given player.""" + player = self.mass.players.get(player_id, True) + self.logger.info( + "Playing announcement %s on %s", + announcement.uri, + player.display_name, + ) + if volume_level is not None: + self.logger.warning( + "Announcement volume level is not supported for player %s", player.display_name + ) await self.hass_prov.hass.call_service( domain="media_player", service="play_media", service_data={ - "media_content_id": media.uri, + "media_content_id": announcement.uri, "media_content_type": "music", - "enqueue": "next", + "announce": True, }, target={"entity_id": player_id}, ) + # Wait until the announcement is finished playing + # This is helpful for people who want to play announcements in a sequence + media_info = await parse_tags(announcement.uri) + duration = media_info.duration or 5 + await asyncio.sleep(duration) + self.logger.debug( + "Playing announcement on %s completed", + player.display_name, + ) async def cmd_power(self, player_id: str, powered: bool) -> None: """Send POWER command to given player. @@ -372,41 +376,60 @@ class HomeAssistantPlayers(PlayerProvider): ) -> None: """Handle setup of a Player from an hass entity.""" hass_device: HassDevice | None = None + hass_domain: str | None = None if entity_registry_entry := entity_registry.get(state["entity_id"]): hass_device = device_registry.get(entity_registry_entry["device_id"]) + hass_domain = entity_registry_entry["platform"] + + dev_info: dict[str, Any] = {} + if hass_device and (model := hass_device.get("model")): + dev_info["model"] = model + if hass_device and (manufacturer := hass_device.get("manufacturer")): + dev_info["manufacturer"] = manufacturer + if hass_device and (model_id := hass_device.get("model_id")): + dev_info["model_id"] = model_id + if hass_device and (sw_version := hass_device.get("sw_version")): + dev_info["software_version"] = sw_version + if hass_device and (connections := hass_device.get("connections")): + for key, value in connections: + if key == "mac": + dev_info["mac_address"] = value + + player = Player( + player_id=state["entity_id"], + provider=self.instance_id, + type=PlayerType.PLAYER, + name=state["attributes"]["friendly_name"], + available=state["state"] not in ("unavailable", "unknown"), + powered=state["state"] not in ("unavailable", "unknown", "standby", "off"), + device_info=DeviceInfo.from_dict(dev_info), + state=StateMap.get(state["state"], PlayerState.IDLE), + ) + # work out supported features hass_supported_features = MediaPlayerEntityFeature( state["attributes"]["supported_features"] ) - supported_features: set[PlayerFeature] = set() if MediaPlayerEntityFeature.PAUSE in hass_supported_features: - supported_features.add(PlayerFeature.PAUSE) + player.supported_features.add(PlayerFeature.PAUSE) if MediaPlayerEntityFeature.VOLUME_SET in hass_supported_features: - supported_features.add(PlayerFeature.VOLUME_SET) + player.supported_features.add(PlayerFeature.VOLUME_SET) if MediaPlayerEntityFeature.VOLUME_MUTE in hass_supported_features: - supported_features.add(PlayerFeature.VOLUME_MUTE) - if MediaPlayerEntityFeature.MEDIA_ENQUEUE in hass_supported_features: - supported_features.add(PlayerFeature.ENQUEUE) + player.supported_features.add(PlayerFeature.VOLUME_MUTE) + if MediaPlayerEntityFeature.MEDIA_ANNOUNCE in hass_supported_features: + player.supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT) + if hass_domain and MediaPlayerEntityFeature.GROUPING in hass_supported_features: + player.supported_features.add(PlayerFeature.SET_MEMBERS) + player.can_group_with = { + x["entity_id"] + for x in entity_registry.values() + if x["entity_id"].startswith("media_player") and x["platform"] == hass_domain + } if ( MediaPlayerEntityFeature.TURN_ON in hass_supported_features and MediaPlayerEntityFeature.TURN_OFF in hass_supported_features ): - supported_features.add(PlayerFeature.POWER) - player = Player( - player_id=state["entity_id"], - provider=self.instance_id, - type=PlayerType.PLAYER, - name=state["attributes"]["friendly_name"], - available=state["state"] not in ("unavailable", "unknown"), - powered=state["state"] not in ("unavailable", "unknown", "standby", "off"), - device_info=DeviceInfo( - model=hass_device["model"] if hass_device else "Unknown model", - manufacturer=( - hass_device["manufacturer"] if hass_device else "Unknown Manufacturer" - ), - ), - supported_features=supported_features, - state=StateMap.get(state["state"], PlayerState.IDLE), - ) + player.supported_features.add(PlayerFeature.POWER) + self._update_player_attributes(player, state["attributes"]) await self.mass.players.register_or_update(player) -- 2.34.1