/
/
/
1"""Alexa player provider support for Music Assistant."""
2
3from __future__ import annotations
4
5import asyncio
6import json
7import logging
8import os
9import time
10from typing import TYPE_CHECKING, Any, cast
11
12import aiohttp
13from aiohttp import BasicAuth
14from alexapy import AlexaAPI, AlexaLogin
15from music_assistant_models.config_entries import (
16 ConfigEntry,
17 ConfigValueOption,
18)
19from music_assistant_models.enums import (
20 ConfigEntryType,
21 PlaybackState,
22 PlayerFeature,
23 ProviderFeature,
24)
25from music_assistant_models.errors import ActionUnavailable
26from music_assistant_models.player import DeviceInfo, PlayerMedia
27
28from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
29from music_assistant.helpers.util import lock
30from music_assistant.models.player import Player
31from music_assistant.models.player_provider import PlayerProvider
32
33_LOGGER = logging.getLogger(__name__)
34
35if TYPE_CHECKING:
36 from music_assistant_models.config_entries import ProviderConfig
37 from music_assistant_models.provider import ProviderManifest
38
39 from music_assistant.mass import MusicAssistant
40 from music_assistant.models import ProviderInstanceType
41
42CONF_URL = "url"
43CONF_AUTH_SECRET = "secret"
44CONF_API_BASIC_AUTH_USERNAME = "api_username"
45CONF_API_BASIC_AUTH_PASSWORD = "api_password"
46CONF_API_URL = "api_url"
47CONF_ALEXA_LANGUAGE = "alexa_language"
48
49ALEXA_LANGUAGE_COMMANDS = {
50 "play_audio_de-DE": "sag music assistant spiele audio",
51 "play_audio_en-CA": "ask music assistant to play audio",
52 "play_audio_en-US": "ask music assistant to play audio",
53 "play_audio_es-ES": "pÃdele a music assistant que reproduzca audio",
54 "play_audio_fr-CA": "music assistant",
55 "play_audio_fr-FR": "music assistant",
56 "play_audio_it-IT": "chiedi a music assistant di riprodurre audio",
57 "play_audio_pt-BR": "peça ao music assistant para reproduzir áudio",
58 "play_audio_nl-NL": "speel audio af op music assistant",
59 "play_audio_default": "ask music assistant to play audio",
60}
61
62SUPPORTED_FEATURES: set[ProviderFeature] = set() # no special features supported (yet)
63
64
65async def setup(
66 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
67) -> ProviderInstanceType:
68 """Initialize provider(instance) with given configuration."""
69 return AlexaProvider(mass, manifest, config, SUPPORTED_FEATURES)
70
71
72async def save_cookie(login: AlexaLogin, username: str, mass: MusicAssistant) -> None:
73 """Save the cookie file for the Alexa login."""
74 if login._session is None:
75 _LOGGER.error("AlexaLogin session is not initialized.")
76 return
77
78 cookie_dir = os.path.join(mass.storage_path, ".alexa")
79 await asyncio.to_thread(os.makedirs, cookie_dir, exist_ok=True)
80 cookie_path = os.path.join(cookie_dir, f"alexa_media.{username}.pickle")
81 login._cookiefile = [login._outputpath(cookie_path)]
82 if (login._cookiefile[0]) and await asyncio.to_thread(os.path.exists, login._cookiefile[0]):
83 _LOGGER.debug("Removing outdated cookiefile %s", login._cookiefile[0])
84 await delete_cookie(login._cookiefile[0])
85 cookie_jar = login._session.cookie_jar
86 assert isinstance(cookie_jar, aiohttp.CookieJar)
87 if login._debug:
88 _LOGGER.debug("Saving cookie to %s", login._cookiefile[0])
89 try:
90 await asyncio.to_thread(cookie_jar.save, login._cookiefile[0])
91 except OSError, EOFError, TypeError, AttributeError:
92 _LOGGER.debug("Error saving pickled cookie to %s", login._cookiefile[0])
93
94
95async def delete_cookie(cookiefile: str) -> None:
96 """Delete the specified cookie file."""
97 if await asyncio.to_thread(os.path.exists, cookiefile):
98 try:
99 await asyncio.to_thread(os.remove, cookiefile)
100 _LOGGER.debug("Deleted cookie file: %s", cookiefile)
101 except OSError as e:
102 _LOGGER.error("Failed to delete cookie file %s: %s", cookiefile, e)
103 else:
104 _LOGGER.debug("Cookie file %s does not exist, nothing to delete.", cookiefile)
105
106
107async def load_cookie(login: AlexaLogin) -> dict[str, str] | None:
108 """
109 Restore a previously saved Alexa session into the login's aiohttp session.
110
111 :param login: The AlexaLogin whose session cookie jar should be populated.
112 """
113 cookiefile = login._cookiefile[0] if login._cookiefile else None
114 if not cookiefile or not await asyncio.to_thread(os.path.exists, cookiefile):
115 return None
116 if login._session is None:
117 login._create_session()
118 # aiohttp 3.14 saves the cookie jar as JSON, which alexapy's own load_cookie()
119 # cannot parse. Load it with aiohttp's loader into the session jar (preserving
120 # the cookie domains required for auth) and return the cookies for login().
121 cookie_jar = login._session.cookie_jar
122 assert isinstance(cookie_jar, aiohttp.CookieJar)
123 try:
124 await asyncio.to_thread(cookie_jar.load, cookiefile)
125 except (OSError, EOFError, TypeError, ValueError, AttributeError) as ex:
126 _LOGGER.debug("Error loading cookie from %s: %s", cookiefile, ex)
127 return None
128 cookies = login._get_cookies_from_session()
129 return cast("dict[str, str]", cookies) if cookies else None
130
131
132async def _request_with_session(
133 session: aiohttp.ClientSession,
134 method: str,
135 url: str,
136 json_data: dict[str, Any] | None,
137 timeout: int,
138 auth: BasicAuth | None,
139) -> str:
140 """
141 Handle an API request with a provided aiohttp session.
142
143 :param session: The aiohttp session to use.
144 :param method: HTTP method to use for the request.
145 :param url: Full URL for the request.
146 :param json_data: Optional JSON payload or query params.
147 :param timeout: Timeout in seconds for the request.
148 :param auth: Optional basic auth credentials.
149 """
150 request_timeout = aiohttp.ClientTimeout(total=timeout)
151 if method.upper() == "GET":
152 async with session.get(url, params=json_data, timeout=request_timeout, auth=auth) as resp:
153 resp_text = await resp.text()
154 if resp.status < 200 or resp.status >= 300:
155 msg = (
156 f"Failed API request to {url}: Status code: {resp.status}, "
157 f"Response: {resp_text}"
158 )
159 _LOGGER.error(msg)
160 raise ActionUnavailable(msg)
161 return resp_text
162
163 async with session.request(
164 method.upper(),
165 url,
166 json=json_data,
167 timeout=request_timeout,
168 auth=auth,
169 ) as resp:
170 resp_text = await resp.text()
171 if resp.status < 200 or resp.status >= 300:
172 msg = f"Failed API request to {url}: Status code: {resp.status}, Response: {resp_text}"
173 _LOGGER.error(msg)
174 raise ActionUnavailable(msg)
175 return resp_text
176
177
178async def api_request(
179 provider: PlayerProvider,
180 endpoint: str,
181 method: str = "POST",
182 json_data: dict[str, Any] | None = None,
183 timeout: int = 10,
184) -> str:
185 """
186 Send a request to the configured Music Assistant / Alexa API.
187
188 Returns the response text on success or raises `ActionUnavailable` on failure.
189 """
190 username = provider.get_setup_value(CONF_API_BASIC_AUTH_USERNAME)
191 password = provider.get_setup_value(CONF_API_BASIC_AUTH_PASSWORD)
192
193 auth = None
194 if username is not None and password is not None:
195 auth = BasicAuth(str(username), str(password))
196
197 api_url = str(provider.get_setup_value(CONF_API_URL) or "")
198 url = f"{api_url.rstrip('/')}/{endpoint.lstrip('/')}"
199
200 return await _request_with_session(
201 provider.mass.http_session, method, url, json_data, timeout, auth
202 )
203
204
205class AlexaDevice:
206 """Representation of an Alexa Device."""
207
208 _device_type: str
209 device_serial_number: str
210 _device_family: str
211 _cluster_members: str
212 _locale: str
213
214
215class AlexaPlayer(Player):
216 """Implementation of an Alexa Player."""
217
218 def __init__(
219 self,
220 provider: AlexaProvider,
221 player_id: str,
222 device: AlexaDevice,
223 ) -> None:
224 """Initialize AlexaPlayer."""
225 super().__init__(provider, player_id)
226 self.device = device
227 self._attr_supported_features = {
228 PlayerFeature.PLAY_MEDIA,
229 PlayerFeature.VOLUME_SET,
230 PlayerFeature.PAUSE,
231 }
232 self._attr_name = player_id
233 self._attr_device_info = DeviceInfo()
234 self._attr_powered = False
235 self._attr_available = True
236 # Keep track of the last metadata we pushed to avoid unnecessary uploads
237 self._last_meta_checksum: str | None = None
238 # Keep last stream url pushed (set in play_media)
239 self._last_stream_url: str | None = None
240
241 @property
242 def requires_flow_mode(self) -> bool:
243 """Return if the player requires flow mode."""
244 return True
245
246 @property
247 def api(self) -> AlexaAPI:
248 """Get the AlexaAPI instance for this player."""
249 provider = cast("AlexaProvider", self.provider)
250 return AlexaAPI(self.device, provider.login)
251
252 async def stop(self) -> None:
253 """Handle STOP command on the player."""
254 provider = cast("AlexaProvider", self.provider)
255
256 utter = await provider.get_intent_utterance("AMAZON.StopIntent", "stop")
257 await self.api.run_custom(utter)
258
259 self._attr_current_media = None
260 self._attr_playback_state = PlaybackState.IDLE
261 self.update_state()
262
263 async def play(self) -> None:
264 """Handle PLAY command on the player."""
265 provider = cast("AlexaProvider", self.provider)
266
267 utter = await provider.get_intent_utterance("AMAZON.ResumeIntent", "resume")
268 await self.api.run_custom(utter)
269
270 self._attr_playback_state = PlaybackState.PLAYING
271 self.update_state()
272
273 async def pause(self) -> None:
274 """Handle PAUSE command on the player."""
275 provider = cast("AlexaProvider", self.provider)
276
277 utter = await provider.get_intent_utterance("AMAZON.PauseIntent", "pause")
278 await self.api.run_custom(utter)
279
280 self._attr_playback_state = PlaybackState.PAUSED
281 self.update_state()
282
283 async def volume_set(self, volume_level: int) -> None:
284 """Handle VOLUME_SET command on the player."""
285 await self.api.set_volume(volume_level / 100)
286 self._attr_volume_level = volume_level
287 self.update_state()
288
289 async def play_media(self, media: PlayerMedia) -> None:
290 """Handle PLAY MEDIA on the player."""
291 stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
292
293 payload = {
294 "streamUrl": stream_url,
295 "title": media.title,
296 "artist": media.artist,
297 "album": media.album,
298 "imageUrl": media.image_url,
299 }
300
301 await api_request(
302 self.provider,
303 "/ma/push-url",
304 method="POST",
305 json_data=payload,
306 timeout=10,
307 )
308
309 # Save last pushed stream url so metadata updates can reuse it
310 self._last_stream_url = stream_url
311
312 alexa_locale = self.provider.config.get_value(CONF_ALEXA_LANGUAGE)
313
314 ask_command_key = f"play_audio_{alexa_locale or 'default'}"
315
316 if ask_command_key not in ALEXA_LANGUAGE_COMMANDS:
317 _LOGGER.debug(
318 "Ask command key %s not found in ALEXA_LANGUAGE_COMMANDS.",
319 ask_command_key,
320 )
321 ask_command_key = "play_audio_default"
322
323 _LOGGER.debug(
324 "Using ask command key: %s -> %s",
325 ask_command_key,
326 ALEXA_LANGUAGE_COMMANDS[ask_command_key],
327 )
328
329 await self.api.run_custom(ALEXA_LANGUAGE_COMMANDS[ask_command_key])
330 self._attr_elapsed_time = 0
331 self._attr_elapsed_time_last_updated = time.time()
332 self._attr_playback_state = PlaybackState.PLAYING
333 self._attr_current_media = media
334 self.update_state()
335
336 def on_player_media_updated(self) -> None:
337 """
338 Handle callback when the current media of the player is updated.
339
340 Upload the stream URL and media metadata (title/artist/album/imageUrl)
341 to the configured Music Assistant / Alexa API so the Alexa side can
342 display/update the playing item.
343 """
344 if self._last_stream_url is None:
345 return
346
347 media = self.state.current_media
348
349 async def _upload_metadata() -> None:
350 stream_url = self._last_stream_url
351 if media is not None:
352 title = media.title
353 artist = media.artist
354 album = media.album
355 image_url = media.image_url
356 else:
357 return
358
359 meta_checksum = f"{stream_url}-{album}-{artist}-{title}-{image_url}"
360 if meta_checksum == self._last_meta_checksum:
361 return
362
363 payload = {
364 "streamUrl": stream_url,
365 "title": title,
366 "artist": artist,
367 "album": album,
368 "imageUrl": image_url,
369 }
370
371 await api_request(
372 self.provider, "/ma/push-url", method="POST", json_data=payload, timeout=10
373 )
374
375 # store last pushed values
376 self._last_meta_checksum = meta_checksum
377
378 self.mass.create_task(_upload_metadata())
379
380
381class AlexaProvider(PlayerProvider):
382 """Implementation of an Alexa Device Provider."""
383
384 login: AlexaLogin
385 devices: dict[str, AlexaDevice]
386
387 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
388 """
389 Return the config entries for the Alexa provider.
390
391 Amazon credentials, the companion API details and the login are collected by the
392 interactive setup flow (see ``setup_flow.py``); only the language option is
393 configurable here.
394 """
395 return (
396 ConfigEntry(
397 key=CONF_ALEXA_LANGUAGE,
398 type=ConfigEntryType.STRING,
399 required=True,
400 options=[
401 ConfigValueOption("en-US"),
402 ConfigValueOption("en-CA"),
403 ConfigValueOption("de-DE"),
404 ConfigValueOption("es-ES"),
405 ConfigValueOption("fr-FR"),
406 ConfigValueOption("fr-CA"),
407 ConfigValueOption("it-IT"),
408 ConfigValueOption("pt-BR"),
409 ConfigValueOption("nl-NL"),
410 ],
411 default_value="en-US", # choose a sensible default
412 ),
413 )
414
415 async def handle_async_init(self) -> None:
416 """Handle async initialization of the provider."""
417 self.devices = {}
418 self._intents: list[dict[str, Any]] | None = None
419 self._invocation_name: str | None = None
420
421 async def loaded_in_mass(self) -> None:
422 """Call after the provider has been loaded."""
423 self.login = AlexaLogin(
424 url=str(self.get_setup_value(CONF_URL)),
425 email=str(self.get_setup_value(CONF_USERNAME)),
426 password=str(self.get_setup_value(CONF_PASSWORD)),
427 outputpath=lambda x: x,
428 )
429
430 cookie_dir = os.path.join(self.mass.storage_path, ".alexa")
431 await asyncio.to_thread(os.makedirs, cookie_dir, exist_ok=True)
432 cookie_path = os.path.join(
433 cookie_dir, f"alexa_media.{self.get_setup_value(CONF_USERNAME)}.pickle"
434 )
435 self.login._cookiefile = [self.login._outputpath(cookie_path)]
436
437 await self.login.login(cookies=await load_cookie(self.login))
438
439 devices = await AlexaAPI.get_devices(self.login)
440
441 if devices is None:
442 return
443
444 alexa_locale = str(self.config.get_value(CONF_ALEXA_LANGUAGE, "en-US"))
445
446 for device in devices:
447 if device.get("capabilities") and "MUSIC_SKILL" in device.get("capabilities"):
448 dev_name = device["accountName"]
449 player_id = dev_name
450 # Initialize AlexaDevice
451 device_object = AlexaDevice()
452 device_object._device_type = device["deviceType"]
453 device_object.device_serial_number = device["serialNumber"]
454 device_object._device_family = device["deviceOwnerCustomerId"]
455 device_object._cluster_members = device["clusterMembers"]
456 device_object._locale = alexa_locale
457 self.devices[player_id] = device_object
458
459 # Create AlexaPlayer instance
460 player = AlexaPlayer(self, player_id, device_object)
461 await self.mass.players.register_or_update(player)
462
463 await self._load_intents()
464
465 @lock
466 async def _load_intents(self) -> None:
467 """Load intents from the configured API and cache them on the provider."""
468 resp = await api_request(self, "/alexa/intents", method="GET", timeout=5)
469 data = json.loads(resp)
470 if isinstance(data, dict):
471 # cache invocationName if present
472 self._invocation_name = data.get("invocationName")
473 self._intents = data.get("intents", []) or []
474 else:
475 self._intents = []
476
477 async def get_intent_utterance(self, intent_name: str, default: str) -> str:
478 """
479 Return the first utterance for the given intent name (cached).
480
481 If intents are not yet cached, attempt to load them.
482 """
483 if self._intents is None:
484 await self._load_intents()
485
486 for intent in self._intents or []:
487 if intent.get("intent") == intent_name:
488 utts = cast("list[str]", intent.get("utterances") or [])
489 if utts:
490 utter = utts[0]
491 if self._invocation_name:
492 inv = self._invocation_name.strip()
493 return f"{inv} {utter}".strip()
494 return utter
495 return default
496