From: Ulrich Lichtenegger Date: Mon, 2 Feb 2026 11:48:21 +0000 (+0100) Subject: hass_players: Show now playing info from external playback (#3015) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=6fa3b738cc82562e3055dc07bf7da6fcb6a89cac;p=music-assistant-server.git hass_players: Show now playing info from external playback (#3015) * HASS Provider: Show now-playing information if playback is steered externally * Fix: Prev/Next action availability not updated in hass player --- diff --git a/music_assistant/providers/hass/__init__.py b/music_assistant/providers/hass/__init__.py index 828c91b7..9016f33d 100644 --- a/music_assistant/providers/hass/__init__.py +++ b/music_assistant/providers/hass/__init__.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio import logging +import os from functools import partial from typing import TYPE_CHECKING, cast @@ -584,3 +585,21 @@ class HomeAssistantProvider(PluginProvider): except Exception as err: self.logger.warning("Failed to get HA user details: %s", err) return None, None, None + + async def resolve_image(self, path: str) -> bytes: + """Resolve an image from an image path.""" + ha_url = cast("str", self.config.get_value(CONF_URL)).rstrip("/") + if ha_url.endswith("/api") and path.startswith("/api/"): + url = f"{ha_url}{path[4:]}" + else: + url = f"{ha_url}{path}" + + # Use HASSIO_TOKEN when running as addon (token config is None) + token = self.config.get_value(CONF_AUTH_TOKEN) or os.environ.get("HASSIO_TOKEN") + headers = {"Authorization": f"Bearer {token}"} if token else {} + + ssl = bool(self.config.get_value(CONF_VERIFY_SSL)) + http_session = self.mass.http_session if ssl else self.mass.http_session_no_ssl + async with http_session.get(url, headers=headers) as response: + response.raise_for_status() + return await response.read() diff --git a/music_assistant/providers/hass_players/player.py b/music_assistant/providers/hass_players/player.py index 4f3f6f9b..f2d77111 100644 --- a/music_assistant/providers/hass_players/player.py +++ b/music_assistant/providers/hass_players/player.py @@ -4,10 +4,17 @@ from __future__ import annotations import asyncio import time -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from hass_client.exceptions import FailedCommand -from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType +from music_assistant_models.enums import ( + ImageType, + MediaType, + PlaybackState, + PlayerFeature, + PlayerType, +) +from music_assistant_models.media_items import MediaItemImage from music_assistant.constants import ( CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN, @@ -19,7 +26,7 @@ from music_assistant.constants import ( ) from music_assistant.helpers.datetime import from_iso_string from music_assistant.helpers.tags import async_parse_tags -from music_assistant.models.player import DeviceInfo, Player, PlayerMedia +from music_assistant.models.player import DeviceInfo, Player, PlayerMedia, PlayerSource from music_assistant.models.player_provider import PlayerProvider from music_assistant.providers.hass.constants import ( OFF_STATES, @@ -38,6 +45,8 @@ if TYPE_CHECKING: from hass_client.models import State as HassState from music_assistant_models.config_entries import ConfigEntry, ConfigValueType + from .provider import HomeAssistantPlayerProvider + DEFAULT_PLAYER_CONFIG_ENTRIES = (CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3,) @@ -71,8 +80,6 @@ class HomeAssistantPlayer(Player): hass_supported_features = MediaPlayerEntityFeature( hass_state["attributes"]["supported_features"] ) - if MediaPlayerEntityFeature.PAUSE in hass_supported_features: - self._attr_supported_features.add(PlayerFeature.PAUSE) if MediaPlayerEntityFeature.VOLUME_SET in hass_supported_features: self._attr_supported_features.add(PlayerFeature.VOLUME_SET) if MediaPlayerEntityFeature.VOLUME_MUTE in hass_supported_features: @@ -95,6 +102,19 @@ class HomeAssistantPlayer(Player): self._attr_powered = hass_state["state"] not in OFF_STATES self.extra_data["hass_supported_features"] = hass_supported_features + self._hass_attributes: dict[str, Any] = {} + + # Add External source to support next/prev commands when playing external content + self._attr_source_list.append( + PlayerSource( + id="External", + name="External Source", + passive=True, + ) + ) + # Set dynamic features (PAUSE, NEXT_PREVIOUS, SEEK) via shared helper + self._update_hass_features(hass_supported_features) + self._update_attributes(hass_state["attributes"]) @property @@ -225,6 +245,22 @@ class HomeAssistantPlayer(Player): target={"entity_id": self.player_id}, ) + async def next_track(self) -> None: + """Handle NEXT_TRACK command on the player.""" + await self.hass.call_service( + domain="media_player", + service="media_next_track", + target={"entity_id": self.player_id}, + ) + + async def previous_track(self) -> None: + """Handle PREVIOUS_TRACK command on the player.""" + await self.hass.call_service( + domain="media_player", + service="media_previous_track", + target={"entity_id": self.player_id}, + ) + async def play_media(self, media: PlayerMedia) -> None: """Handle PLAY MEDIA on given player.""" extra_data: dict[str, Any] = { @@ -342,8 +378,35 @@ class HomeAssistantPlayer(Player): self._update_attributes(state["a"]) self.update_state() + def _update_hass_features(self, hass_supported_features: MediaPlayerEntityFeature) -> None: + """Update player and External source features based on HA supported features.""" + # Update player supported features for PAUSE and NEXT_PREVIOUS + if MediaPlayerEntityFeature.PAUSE in hass_supported_features: + self._attr_supported_features.add(PlayerFeature.PAUSE) + else: + self._attr_supported_features.discard(PlayerFeature.PAUSE) + + has_next_prev = ( + MediaPlayerEntityFeature.NEXT_TRACK in hass_supported_features + or MediaPlayerEntityFeature.PREVIOUS_TRACK in hass_supported_features + ) + if has_next_prev: + self._attr_supported_features.add(PlayerFeature.NEXT_PREVIOUS) + else: + self._attr_supported_features.discard(PlayerFeature.NEXT_PREVIOUS) + + # Update the External source capabilities + for source in self._attr_source_list: + if source.id == "External": + source.can_play_pause = MediaPlayerEntityFeature.PAUSE in hass_supported_features + source.can_next_previous = has_next_prev + source.can_seek = MediaPlayerEntityFeature.SEEK in hass_supported_features + break + def _update_attributes(self, attributes: dict[str, Any]) -> None: """Update Player attributes from HA state attributes.""" + self._hass_attributes.update(attributes) + # process optional attributes - these may not be present in all states for key, value in attributes.items(): if key == "friendly_name": @@ -376,3 +439,71 @@ class HomeAssistantPlayer(Player): self._attr_group_members.clear() else: self._attr_group_members.clear() + elif key == "supported_features": + # Update supported features dynamically via shared helper + hass_supported_features = MediaPlayerEntityFeature(value) + self.extra_data["hass_supported_features"] = hass_supported_features + self._update_hass_features(hass_supported_features) + + # Check for external playback (not from Music Assistant). + # Without media_content_id we cannot reliably determine the source, + # so we later only react to state updates that include it. + media_content_id = self._hass_attributes.get("media_content_id", "") + is_ma_playback = media_content_id.startswith(self.mass.streams.base_url) + + media_title = self._hass_attributes.get("media_title") + + if media_content_id and is_ma_playback: + # MA playback - ensure active_source points to player_id for queue lookup. + # The actual current_media will be set by MA's queue controller. + self._attr_active_source = self.player_id + elif ( + media_content_id + and media_title + and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED) + ): + # External playback detected - set current_media from HA attributes + ha_content_type = self._hass_attributes.get("media_content_type", "") + media_type = MediaType.RADIO if ha_content_type == "radio" else MediaType.UNKNOWN + current_media = PlayerMedia( + uri=media_content_id, + media_type=media_type, + title=media_title, + artist=self._hass_attributes.get("media_artist"), + album=self._hass_attributes.get("media_album_name"), + image_url=self._get_image_url(self._hass_attributes), + duration=int(self._hass_attributes.get("media_duration", 0) or 0) or None, + ) + self._attr_current_media = current_media + self._attr_active_source = "External" + + elif self.playback_state == PlaybackState.IDLE: + # Clear external media if it was set + if self._attr_active_source and self._attr_active_source not in ( + self.player_id, + None, + ): + self._attr_current_media = None + self._attr_active_source = None + + def _get_image_url(self, attributes: dict[str, Any]) -> str | None: + """Get the image URL from the attributes.""" + if entity_picture := attributes.get("entity_picture"): + entity_picture = str(entity_picture) + if entity_picture.startswith("http"): + return entity_picture + + # Access via provider -> hass_prov + prov = cast("HomeAssistantPlayerProvider", self.provider) + + # Use proxy for internal HA images + # We create a MediaItemImage with the hass provider as source + # This will trigger resolve_image on the hass provider when requested + image = MediaItemImage( + type=ImageType.THUMB, + path=entity_picture, + provider=prov.hass_prov.instance_id, + remotely_accessible=False, + ) + return self.mass.metadata.get_image_url(image) + return None