Update Alexa player provider (#3167)
authorSameer Alam <31905246+alams154@users.noreply.github.com>
Mon, 23 Feb 2026 08:32:30 +0000 (02:32 -0600)
committerGitHub <noreply@github.com>
Mon, 23 Feb 2026 08:32:30 +0000 (09:32 +0100)
* Update Alexa player provider

* Remove redundant try catch

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Address PR comments

* Remove ActionUnavailble catches

* Remove extra catch alls and add _on_player_media_updated

* Remove catch all

* Bump AlexaPy

* Fix _upload_metadata when media is not available

---------

Co-authored-by: Sameer Alam <alams154@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
music_assistant/providers/alexa/__init__.py
music_assistant/providers/alexa/manifest.json
requirements_all.txt

index 1b430eb4f149af8dd8f9573085ebb61ffabfccf6..d9e3ef929ed9b2fff157cd727d013bf4e363d85e 100644 (file)
@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import asyncio
+import json
 import logging
 import os
 import time
@@ -23,6 +24,7 @@ from music_assistant_models.player import DeviceInfo, PlayerMedia
 
 from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
 from music_assistant.helpers.auth import AuthenticationHelper
+from music_assistant.helpers.util import lock
 from music_assistant.models.player import Player
 from music_assistant.models.player_provider import PlayerProvider
 
@@ -181,7 +183,7 @@ async def get_config_entries(
             key=CONF_API_URL,
             type=ConfigEntryType.STRING,
             label="API Url",
-            default_value="http://localhost:3000",
+            default_value="http://localhost:5000",
             required=True,
             value=values.get(CONF_API_URL) if values else None,
         ),
@@ -244,6 +246,77 @@ async def delete_cookie(cookiefile: str) -> None:
         _LOGGER.debug("Cookie file %s does not exist, nothing to delete.", cookiefile)
 
 
+async def _request_with_session(
+    session: aiohttp.ClientSession,
+    method: str,
+    url: str,
+    json_data: dict[str, Any] | None,
+    timeout: int,
+    auth: BasicAuth | None,
+) -> str:
+    """Handle an API request with a provided aiohttp session.
+
+    :param session: The aiohttp session to use.
+    :param method: HTTP method to use for the request.
+    :param url: Full URL for the request.
+    :param json_data: Optional JSON payload or query params.
+    :param timeout: Timeout in seconds for the request.
+    :param auth: Optional basic auth credentials.
+    """
+    request_timeout = aiohttp.ClientTimeout(total=timeout)
+    if method.upper() == "GET":
+        async with session.get(url, params=json_data, timeout=request_timeout, auth=auth) as resp:
+            resp_text = await resp.text()
+            if resp.status < 200 or resp.status >= 300:
+                msg = (
+                    f"Failed API request to {url}: Status code: {resp.status}, "
+                    f"Response: {resp_text}"
+                )
+                _LOGGER.error(msg)
+                raise ActionUnavailable(msg)
+            return resp_text
+
+    async with session.request(
+        method.upper(),
+        url,
+        json=json_data,
+        timeout=request_timeout,
+        auth=auth,
+    ) as resp:
+        resp_text = await resp.text()
+        if resp.status < 200 or resp.status >= 300:
+            msg = f"Failed API request to {url}: Status code: {resp.status}, Response: {resp_text}"
+            _LOGGER.error(msg)
+            raise ActionUnavailable(msg)
+        return resp_text
+
+
+async def api_request(
+    provider: PlayerProvider,
+    endpoint: str,
+    method: str = "POST",
+    json_data: dict[str, Any] | None = None,
+    timeout: int = 10,
+) -> str:
+    """Send a request to the configured Music Assistant / Alexa API.
+
+    Returns the response text on success or raises `ActionUnavailable` on failure.
+    """
+    username = provider.config.get_value(CONF_API_BASIC_AUTH_USERNAME)
+    password = provider.config.get_value(CONF_API_BASIC_AUTH_PASSWORD)
+
+    auth = None
+    if username is not None and password is not None:
+        auth = BasicAuth(str(username), str(password))
+
+    api_url = str(provider.config.get_value(CONF_API_URL) or "")
+    url = f"{api_url.rstrip('/')}/{endpoint.lstrip('/')}"
+
+    return await _request_with_session(
+        provider.mass.http_session, method, url, json_data, timeout, auth
+    )
+
+
 class AlexaDevice:
     """Representation of an Alexa Device."""
 
@@ -275,6 +348,10 @@ class AlexaPlayer(Player):
         self._attr_device_info = DeviceInfo()
         self._attr_powered = False
         self._attr_available = True
+        # Keep track of the last metadata we pushed to avoid unnecessary uploads
+        self._last_meta_checksum: str | None = None
+        # Keep last stream url pushed (set in play_media)
+        self._last_stream_url: str | None = None
 
     @property
     def requires_flow_mode(self) -> bool:
@@ -289,20 +366,32 @@ class AlexaPlayer(Player):
 
     async def stop(self) -> None:
         """Handle STOP command on the player."""
-        await self.api.stop()
+        provider = cast("AlexaProvider", self.provider)
+
+        utter = await provider.get_intent_utterance("AMAZON.StopIntent", "stop")
+        await self.api.run_custom(utter)
+
         self._attr_current_media = None
         self._attr_playback_state = PlaybackState.IDLE
         self.update_state()
 
     async def play(self) -> None:
         """Handle PLAY command on the player."""
-        await self.api.play()
+        provider = cast("AlexaProvider", self.provider)
+
+        utter = await provider.get_intent_utterance("AMAZON.ResumeIntent", "resume")
+        await self.api.run_custom(utter)
+
         self._attr_playback_state = PlaybackState.PLAYING
         self.update_state()
 
     async def pause(self) -> None:
         """Handle PAUSE command on the player."""
-        await self.api.pause()
+        provider = cast("AlexaProvider", self.provider)
+
+        utter = await provider.get_intent_utterance("AMAZON.PauseIntent", "pause")
+        await self.api.run_custom(utter)
+
         self._attr_playback_state = PlaybackState.PAUSED
         self.update_state()
 
@@ -314,47 +403,22 @@ class AlexaPlayer(Player):
 
     async def play_media(self, media: PlayerMedia) -> None:
         """Handle PLAY MEDIA on the player."""
-        username = self.provider.config.get_value(CONF_API_BASIC_AUTH_USERNAME)
-        password = self.provider.config.get_value(CONF_API_BASIC_AUTH_PASSWORD)
+        stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
 
-        auth = None
-        if username is not None and password is not None:
-            auth = BasicAuth(str(username), str(password))
+        payload = {
+            "streamUrl": stream_url,
+        }
 
-        stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
+        await api_request(
+            self.provider,
+            "/ma/push-url",
+            method="POST",
+            json_data=payload,
+            timeout=10,
+        )
 
-        async with aiohttp.ClientSession() as session:
-            try:
-                async with session.post(
-                    f"{self.provider.config.get_value(CONF_API_URL)}/ma/push-url",
-                    json={
-                        "streamUrl": stream_url,
-                        "title": media.title,
-                        "artist": media.artist,
-                        "album": media.album,
-                        "imageUrl": media.image_url,
-                    },
-                    timeout=aiohttp.ClientTimeout(total=10),
-                    auth=auth,
-                ) as resp:
-                    resp_text = await resp.text()
-                    if resp.status < 200 or resp.status >= 300:
-                        msg = (
-                            f"Failed to push URL to MA Alexa API: "
-                            f"Status code: {resp.status}, Response: {resp_text}. "
-                            "Please verify your API connection and configuration"
-                        )
-                        _LOGGER.error(msg)
-                        raise ActionUnavailable(msg)
-            except ActionUnavailable:
-                raise
-            except Exception as exc:
-                msg = (
-                    "Failed to push URL to MA Alexa API: "
-                    "Please verify your API connection and configuration"
-                )
-                _LOGGER.error("Failed to push URL to MA Alexa API: %s", exc)
-                raise ActionUnavailable(msg)
+        # Save last pushed stream url so metadata updates can reuse it
+        self._last_stream_url = stream_url
 
         alexa_locale = self.provider.config.get_value(CONF_ALEXA_LANGUAGE)
 
@@ -380,6 +444,46 @@ class AlexaPlayer(Player):
         self._attr_current_media = media
         self.update_state()
 
+    def _on_player_media_updated(self) -> None:
+        """Handle callback when the current media of the player is updated.
+
+        Upload the stream URL and media metadata (title/artist/album/imageUrl)
+        to the configured Music Assistant / Alexa API so the Alexa side can
+        display/update the playing item.
+        """
+        media = self.state.current_media
+
+        async def _upload_metadata() -> None:
+            stream_url = self._last_stream_url
+            if media is not None:
+                title = media.title
+                artist = media.artist
+                album = media.album
+                image_url = media.image_url
+            else:
+                return
+
+            meta_checksum = f"{stream_url}-{album}-{artist}-{title}-{image_url}"
+            if meta_checksum == self._last_meta_checksum:
+                return
+
+            payload = {
+                "streamUrl": stream_url,
+                "title": title,
+                "artist": artist,
+                "album": album,
+                "imageUrl": image_url,
+            }
+
+            await api_request(
+                self.provider, "/ma/push-url", method="POST", json_data=payload, timeout=10
+            )
+
+            # store last pushed values
+            self._last_meta_checksum = meta_checksum
+
+        self.mass.create_task(_upload_metadata())
+
 
 class AlexaProvider(PlayerProvider):
     """Implementation of an Alexa Device Provider."""
@@ -390,6 +494,8 @@ class AlexaProvider(PlayerProvider):
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         self.devices = {}
+        self._intents: list[dict[str, Any]] | None = None
+        self._invocation_name: str | None = None
 
     async def loaded_in_mass(self) -> None:
         """Call after the provider has been loaded."""
@@ -432,3 +538,36 @@ class AlexaProvider(PlayerProvider):
                 # Create AlexaPlayer instance
                 player = AlexaPlayer(self, player_id, device_object)
                 await self.mass.players.register_or_update(player)
+
+        await self._load_intents()
+
+    @lock
+    async def _load_intents(self) -> None:
+        """Load intents from the configured API and cache them on the provider."""
+        resp = await api_request(self, "/alexa/intents", method="GET", timeout=5)
+        data = json.loads(resp)
+        if isinstance(data, dict):
+            # cache invocationName if present
+            self._invocation_name = data.get("invocationName")
+            self._intents = data.get("intents", []) or []
+        else:
+            self._intents = []
+
+    async def get_intent_utterance(self, intent_name: str, default: str) -> str:
+        """Return the first utterance for the given intent name (cached).
+
+        If intents are not yet cached, attempt to load them.
+        """
+        if self._intents is None:
+            await self._load_intents()
+
+        for intent in self._intents or []:
+            if intent.get("intent") == intent_name:
+                utts = cast("list[str]", intent.get("utterances") or [])
+                if utts:
+                    utter = utts[0]
+                    if self._invocation_name:
+                        inv = self._invocation_name.strip()
+                        return f"{inv} {utter}".strip()
+                    return utter
+        return default
index 683cf7bc3767c3482fbfcb51a43b1664d1673fe5..898f6df8518d24b047173bde1e00cf31585613cf 100644 (file)
@@ -6,6 +6,6 @@
   "description": "Stream to Amazon Alexa (Echo) devices.",
   "codeowners": ["@alams154"],
   "credits": ["[alexapy](https://gitlab.com/keatontaylor/alexapy)"],
-  "requirements": ["alexapy==1.29.15"],
+  "requirements": ["alexapy==1.29.17"],
   "documentation": "https://www.music-assistant.io/player-support/alexa/"
 }
index 4dee33193e04b74139f68fbc2ff838cb99600b76..b88cd30e73e02b2addce56c6a9420158a8eb139e 100644 (file)
@@ -16,7 +16,7 @@ aioslimproto==3.1.5
 aiosonos==0.1.9
 aiosqlite==0.22.1
 aiovban>=0.6.3
-alexapy==1.29.15
+alexapy==1.29.17
 async-upnp-client==0.46.2
 audible==0.10.0
 auntie-sounds==1.1.7