hass_players: Show now playing info from external playback (#3015)
authorUlrich Lichtenegger <ulilicht@users.noreply.github.com>
Mon, 2 Feb 2026 11:48:21 +0000 (12:48 +0100)
committerGitHub <noreply@github.com>
Mon, 2 Feb 2026 11:48:21 +0000 (12:48 +0100)
* HASS Provider: Show now-playing information if playback is steered externally

* Fix: Prev/Next action availability not updated in hass player

music_assistant/providers/hass/__init__.py
music_assistant/providers/hass_players/player.py

index 828c91b7bc48027d5e12d4ba8d0a4ea1618516c1..9016f33d936b5951569fe43acf91ee22e4b66957 100644 (file)
@@ -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()
index 4f3f6f9b713e8cdcc90779cbb14366c9a554dea1..f2d77111e79728ff1d7f86e5219792d7b43f10c7 100644 (file)
@@ -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