/
/
/
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, web
14from alexapy import AlexaAPI, AlexaLogin, AlexaProxy
15from music_assistant_models.config_entries import ConfigEntry
16from music_assistant_models.enums import (
17 ConfigEntryType,
18 PlaybackState,
19 PlayerFeature,
20 ProviderFeature,
21)
22from music_assistant_models.errors import ActionUnavailable, LoginFailed
23from music_assistant_models.player import DeviceInfo, PlayerMedia
24
25from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
26from music_assistant.helpers.auth import AuthenticationHelper
27from music_assistant.helpers.util import lock
28from music_assistant.models.player import Player
29from music_assistant.models.player_provider import PlayerProvider
30
31_LOGGER = logging.getLogger(__name__)
32
33if TYPE_CHECKING:
34 from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
35 from music_assistant_models.provider import ProviderManifest
36
37 from music_assistant.mass import MusicAssistant
38 from music_assistant.models import ProviderInstanceType
39
40CONF_URL = "url"
41CONF_ACTION_AUTH = "auth"
42CONF_AUTH_SECRET = "secret"
43CONF_API_BASIC_AUTH_USERNAME = "api_username"
44CONF_API_BASIC_AUTH_PASSWORD = "api_password"
45CONF_API_URL = "api_url"
46CONF_ALEXA_LANGUAGE = "alexa_language"
47
48ALEXA_LANGUAGE_COMMANDS = {
49 "play_audio_de-DE": "sag music assistant spiele audio",
50 "play_audio_en-US": "ask music assistant to play audio",
51 "play_audio_es-ES": "pÃdele a music assistant que reproduzca audio",
52 "play_audio_fr-FR": "music assistant",
53 "play_audio_it-IT": "chiedi a music assistant di riprodurre audio",
54 "play_audio_default": "ask music assistant to play audio",
55}
56
57SUPPORTED_FEATURES: set[ProviderFeature] = set() # no special features supported (yet)
58
59
60async def setup(
61 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
62) -> ProviderInstanceType:
63 """Initialize provider(instance) with given configuration."""
64 return AlexaProvider(mass, manifest, config, SUPPORTED_FEATURES)
65
66
67async def get_config_entries(
68 mass: MusicAssistant,
69 instance_id: str | None = None,
70 action: str | None = None,
71 values: dict[str, ConfigValueType] | None = None,
72) -> tuple[ConfigEntry, ...]:
73 """
74 Return Config entries to setup this provider.
75
76 instance_id: id of an existing provider instance (None if new instance setup).
77 action: [optional] action key called from config entries UI.
78 values: the (intermediate) raw values for config entries sent with the action.
79 """
80 # ruff: noqa: ARG001
81 # config flow auth action/step (authenticate button clicked)
82 if action == CONF_ACTION_AUTH and values:
83 async with AuthenticationHelper(mass, str(values["session_id"])) as auth_helper:
84 login = AlexaLogin(
85 url=str(values[CONF_URL]),
86 email=str(values[CONF_USERNAME]),
87 password=str(values[CONF_PASSWORD]),
88 otp_secret=str(values.get(CONF_AUTH_SECRET, "")),
89 outputpath=lambda x: x,
90 )
91
92 # --- Proxy authentication logic using AlexaProxy ---
93 # Build the proxy path and URL
94 proxy_path = "/alexa/auth/proxy/"
95 post_path = "/alexa/auth/proxy/ap/signin/*"
96 base_url = mass.webserver.base_url.rstrip("/")
97 proxy_url = f"{base_url}{proxy_path}"
98
99 # Create AlexaProxy instance
100 proxy = AlexaProxy(login, proxy_url)
101
102 # Handler that delegates to AlexaProxy's all_handler
103 async def proxy_handler(request: web.Request) -> Any:
104 response = await proxy.all_handler(request)
105 if "Successfully logged in" in getattr(response, "text", ""):
106 # Notify the callback URL
107 async with aiohttp.ClientSession() as session:
108 await session.get(auth_helper.callback_url)
109 _LOGGER.info("Alexa Callback URL: %s", auth_helper.callback_url)
110 return web.Response(
111 text="""
112 <html>
113 <body>
114 <h2>Login successful!</h2>
115 <p>You may now close this window.</p>
116 </body>
117 </html>
118 """,
119 content_type="text/html",
120 )
121 return response
122
123 # Register GET for the base proxy path
124 mass.webserver.register_dynamic_route(proxy_path, proxy_handler, "GET")
125 # Register POST for the specific signin helper path
126 mass.webserver.register_dynamic_route(post_path, proxy_handler, "POST")
127
128 try:
129 await auth_helper.authenticate(proxy_url)
130 if await login.test_loggedin():
131 await save_cookie(login, str(values[CONF_USERNAME]), mass)
132 else:
133 raise LoginFailed(
134 "Authentication login failed, please provide logs to the discussion #431."
135 )
136 except KeyError:
137 # no URL param was found so user probably cancelled the auth
138 pass
139 except Exception as error:
140 raise LoginFailed(f"Failed to authenticate with Amazon '{error}'.")
141 finally:
142 mass.webserver.unregister_dynamic_route(proxy_path, "GET")
143 mass.webserver.unregister_dynamic_route(post_path, "POST")
144
145 return (
146 ConfigEntry(
147 key=CONF_URL,
148 type=ConfigEntryType.STRING,
149 label="URL",
150 required=True,
151 default_value="amazon.com",
152 ),
153 ConfigEntry(
154 key=CONF_USERNAME,
155 type=ConfigEntryType.STRING,
156 label="E-Mail",
157 required=True,
158 value=values.get(CONF_USERNAME) if values else None,
159 ),
160 ConfigEntry(
161 key=CONF_PASSWORD,
162 type=ConfigEntryType.SECURE_STRING,
163 label="Password",
164 required=True,
165 value=values.get(CONF_PASSWORD) if values else None,
166 ),
167 ConfigEntry(
168 key=CONF_AUTH_SECRET,
169 type=ConfigEntryType.SECURE_STRING,
170 label="OTP Secret",
171 required=False,
172 value=values.get(CONF_AUTH_SECRET) if values else None,
173 ),
174 ConfigEntry(
175 key=CONF_ACTION_AUTH,
176 type=ConfigEntryType.ACTION,
177 label="Authenticate with Amazon",
178 description="Click to start the authentication process.",
179 action=CONF_ACTION_AUTH,
180 depends_on=CONF_URL,
181 ),
182 ConfigEntry(
183 key=CONF_API_URL,
184 type=ConfigEntryType.STRING,
185 label="API Url",
186 default_value="http://localhost:5000",
187 required=True,
188 value=values.get(CONF_API_URL) if values else None,
189 ),
190 ConfigEntry(
191 key=CONF_API_BASIC_AUTH_USERNAME,
192 type=ConfigEntryType.STRING,
193 label="API Basic Auth Username",
194 required=False,
195 value=values.get(CONF_API_BASIC_AUTH_USERNAME) if values else None,
196 ),
197 ConfigEntry(
198 key=CONF_API_BASIC_AUTH_PASSWORD,
199 type=ConfigEntryType.SECURE_STRING,
200 label="API Basic Auth Password",
201 required=False,
202 value=values.get(CONF_API_BASIC_AUTH_PASSWORD) if values else None,
203 ),
204 ConfigEntry(
205 key=CONF_ALEXA_LANGUAGE,
206 type=ConfigEntryType.STRING,
207 label="Alexa Language",
208 required=True,
209 default_value="en-US",
210 ),
211 )
212
213
214async def save_cookie(login: AlexaLogin, username: str, mass: MusicAssistant) -> None:
215 """Save the cookie file for the Alexa login."""
216 if login._session is None:
217 _LOGGER.error("AlexaLogin session is not initialized.")
218 return
219
220 cookie_dir = os.path.join(mass.storage_path, ".alexa")
221 await asyncio.to_thread(os.makedirs, cookie_dir, exist_ok=True)
222 cookie_path = os.path.join(cookie_dir, f"alexa_media.{username}.pickle")
223 login._cookiefile = [login._outputpath(cookie_path)]
224 if (login._cookiefile[0]) and await asyncio.to_thread(os.path.exists, login._cookiefile[0]):
225 _LOGGER.debug("Removing outdated cookiefile %s", login._cookiefile[0])
226 await delete_cookie(login._cookiefile[0])
227 cookie_jar = login._session.cookie_jar
228 assert isinstance(cookie_jar, aiohttp.CookieJar)
229 if login._debug:
230 _LOGGER.debug("Saving cookie to %s", login._cookiefile[0])
231 try:
232 await asyncio.to_thread(cookie_jar.save, login._cookiefile[0])
233 except (OSError, EOFError, TypeError, AttributeError):
234 _LOGGER.debug("Error saving pickled cookie to %s", login._cookiefile[0])
235
236
237async def delete_cookie(cookiefile: str) -> None:
238 """Delete the specified cookie file."""
239 if await asyncio.to_thread(os.path.exists, cookiefile):
240 try:
241 await asyncio.to_thread(os.remove, cookiefile)
242 _LOGGER.debug("Deleted cookie file: %s", cookiefile)
243 except OSError as e:
244 _LOGGER.error("Failed to delete cookie file %s: %s", cookiefile, e)
245 else:
246 _LOGGER.debug("Cookie file %s does not exist, nothing to delete.", cookiefile)
247
248
249async def _request_with_session(
250 session: aiohttp.ClientSession,
251 method: str,
252 url: str,
253 json_data: dict[str, Any] | None,
254 timeout: int,
255 auth: BasicAuth | None,
256) -> str:
257 """Handle an API request with a provided aiohttp session.
258
259 :param session: The aiohttp session to use.
260 :param method: HTTP method to use for the request.
261 :param url: Full URL for the request.
262 :param json_data: Optional JSON payload or query params.
263 :param timeout: Timeout in seconds for the request.
264 :param auth: Optional basic auth credentials.
265 """
266 request_timeout = aiohttp.ClientTimeout(total=timeout)
267 if method.upper() == "GET":
268 async with session.get(url, params=json_data, timeout=request_timeout, auth=auth) as resp:
269 resp_text = await resp.text()
270 if resp.status < 200 or resp.status >= 300:
271 msg = (
272 f"Failed API request to {url}: Status code: {resp.status}, "
273 f"Response: {resp_text}"
274 )
275 _LOGGER.error(msg)
276 raise ActionUnavailable(msg)
277 return resp_text
278
279 async with session.request(
280 method.upper(),
281 url,
282 json=json_data,
283 timeout=request_timeout,
284 auth=auth,
285 ) as resp:
286 resp_text = await resp.text()
287 if resp.status < 200 or resp.status >= 300:
288 msg = f"Failed API request to {url}: Status code: {resp.status}, Response: {resp_text}"
289 _LOGGER.error(msg)
290 raise ActionUnavailable(msg)
291 return resp_text
292
293
294async def api_request(
295 provider: PlayerProvider,
296 endpoint: str,
297 method: str = "POST",
298 json_data: dict[str, Any] | None = None,
299 timeout: int = 10,
300) -> str:
301 """Send a request to the configured Music Assistant / Alexa API.
302
303 Returns the response text on success or raises `ActionUnavailable` on failure.
304 """
305 username = provider.config.get_value(CONF_API_BASIC_AUTH_USERNAME)
306 password = provider.config.get_value(CONF_API_BASIC_AUTH_PASSWORD)
307
308 auth = None
309 if username is not None and password is not None:
310 auth = BasicAuth(str(username), str(password))
311
312 api_url = str(provider.config.get_value(CONF_API_URL) or "")
313 url = f"{api_url.rstrip('/')}/{endpoint.lstrip('/')}"
314
315 return await _request_with_session(
316 provider.mass.http_session, method, url, json_data, timeout, auth
317 )
318
319
320class AlexaDevice:
321 """Representation of an Alexa Device."""
322
323 _device_type: str
324 device_serial_number: str
325 _device_family: str
326 _cluster_members: str
327 _locale: str
328
329
330class AlexaPlayer(Player):
331 """Implementation of an Alexa Player."""
332
333 def __init__(
334 self,
335 provider: AlexaProvider,
336 player_id: str,
337 device: AlexaDevice,
338 ) -> None:
339 """Initialize AlexaPlayer."""
340 super().__init__(provider, player_id)
341 self.device = device
342 self._attr_supported_features = {
343 PlayerFeature.PLAY_MEDIA,
344 PlayerFeature.VOLUME_SET,
345 PlayerFeature.PAUSE,
346 }
347 self._attr_name = player_id
348 self._attr_device_info = DeviceInfo()
349 self._attr_powered = False
350 self._attr_available = True
351 # Keep track of the last metadata we pushed to avoid unnecessary uploads
352 self._last_meta_checksum: str | None = None
353 # Keep last stream url pushed (set in play_media)
354 self._last_stream_url: str | None = None
355
356 @property
357 def requires_flow_mode(self) -> bool:
358 """Return if the player requires flow mode."""
359 return True
360
361 @property
362 def api(self) -> AlexaAPI:
363 """Get the AlexaAPI instance for this player."""
364 provider = cast("AlexaProvider", self.provider)
365 return AlexaAPI(self.device, provider.login)
366
367 async def stop(self) -> None:
368 """Handle STOP command on the player."""
369 provider = cast("AlexaProvider", self.provider)
370
371 utter = await provider.get_intent_utterance("AMAZON.StopIntent", "stop")
372 await self.api.run_custom(utter)
373
374 self._attr_current_media = None
375 self._attr_playback_state = PlaybackState.IDLE
376 self.update_state()
377
378 async def play(self) -> None:
379 """Handle PLAY command on the player."""
380 provider = cast("AlexaProvider", self.provider)
381
382 utter = await provider.get_intent_utterance("AMAZON.ResumeIntent", "resume")
383 await self.api.run_custom(utter)
384
385 self._attr_playback_state = PlaybackState.PLAYING
386 self.update_state()
387
388 async def pause(self) -> None:
389 """Handle PAUSE command on the player."""
390 provider = cast("AlexaProvider", self.provider)
391
392 utter = await provider.get_intent_utterance("AMAZON.PauseIntent", "pause")
393 await self.api.run_custom(utter)
394
395 self._attr_playback_state = PlaybackState.PAUSED
396 self.update_state()
397
398 async def volume_set(self, volume_level: int) -> None:
399 """Handle VOLUME_SET command on the player."""
400 await self.api.set_volume(volume_level / 100)
401 self._attr_volume_level = volume_level
402 self.update_state()
403
404 async def play_media(self, media: PlayerMedia) -> None:
405 """Handle PLAY MEDIA on the player."""
406 stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
407
408 payload = {
409 "streamUrl": stream_url,
410 }
411
412 await api_request(
413 self.provider,
414 "/ma/push-url",
415 method="POST",
416 json_data=payload,
417 timeout=10,
418 )
419
420 # Save last pushed stream url so metadata updates can reuse it
421 self._last_stream_url = stream_url
422
423 alexa_locale = self.provider.config.get_value(CONF_ALEXA_LANGUAGE)
424
425 ask_command_key = f"play_audio_{alexa_locale if alexa_locale else 'default'}"
426
427 if ask_command_key not in ALEXA_LANGUAGE_COMMANDS:
428 _LOGGER.debug(
429 "Ask command key %s not found in ALEXA_LANGUAGE_COMMANDS.",
430 ask_command_key,
431 )
432 ask_command_key = "play_audio_default"
433
434 _LOGGER.debug(
435 "Using ask command key: %s -> %s",
436 ask_command_key,
437 ALEXA_LANGUAGE_COMMANDS[ask_command_key],
438 )
439
440 await self.api.run_custom(ALEXA_LANGUAGE_COMMANDS[ask_command_key])
441 self._attr_elapsed_time = 0
442 self._attr_elapsed_time_last_updated = time.time()
443 self._attr_playback_state = PlaybackState.PLAYING
444 self._attr_current_media = media
445 self.update_state()
446
447 def _on_player_media_updated(self) -> None:
448 """Handle callback when the current media of the player is updated.
449
450 Upload the stream URL and media metadata (title/artist/album/imageUrl)
451 to the configured Music Assistant / Alexa API so the Alexa side can
452 display/update the playing item.
453 """
454 media = self.state.current_media
455
456 async def _upload_metadata() -> None:
457 stream_url = self._last_stream_url
458 if media is not None:
459 title = media.title
460 artist = media.artist
461 album = media.album
462 image_url = media.image_url
463 else:
464 return
465
466 meta_checksum = f"{stream_url}-{album}-{artist}-{title}-{image_url}"
467 if meta_checksum == self._last_meta_checksum:
468 return
469
470 payload = {
471 "streamUrl": stream_url,
472 "title": title,
473 "artist": artist,
474 "album": album,
475 "imageUrl": image_url,
476 }
477
478 await api_request(
479 self.provider, "/ma/push-url", method="POST", json_data=payload, timeout=10
480 )
481
482 # store last pushed values
483 self._last_meta_checksum = meta_checksum
484
485 self.mass.create_task(_upload_metadata())
486
487
488class AlexaProvider(PlayerProvider):
489 """Implementation of an Alexa Device Provider."""
490
491 login: AlexaLogin
492 devices: dict[str, AlexaDevice]
493
494 async def handle_async_init(self) -> None:
495 """Handle async initialization of the provider."""
496 self.devices = {}
497 self._intents: list[dict[str, Any]] | None = None
498 self._invocation_name: str | None = None
499
500 async def loaded_in_mass(self) -> None:
501 """Call after the provider has been loaded."""
502 self.login = AlexaLogin(
503 url=str(self.config.get_value(CONF_URL)),
504 email=str(self.config.get_value(CONF_USERNAME)),
505 password=str(self.config.get_value(CONF_PASSWORD)),
506 outputpath=lambda x: x,
507 )
508
509 cookie_dir = os.path.join(self.mass.storage_path, ".alexa")
510 await asyncio.to_thread(os.makedirs, cookie_dir, exist_ok=True)
511 cookie_path = os.path.join(
512 cookie_dir, f"alexa_media.{self.config.get_value(CONF_USERNAME)}.pickle"
513 )
514 self.login._cookiefile = [self.login._outputpath(cookie_path)]
515
516 await self.login.login(cookies=await self.login.load_cookie())
517
518 devices = await AlexaAPI.get_devices(self.login)
519
520 if devices is None:
521 return
522
523 alexa_locale = str(self.config.get_value(CONF_ALEXA_LANGUAGE, "en-US"))
524
525 for device in devices:
526 if device.get("capabilities") and "MUSIC_SKILL" in device.get("capabilities"):
527 dev_name = device["accountName"]
528 player_id = dev_name
529 # Initialize AlexaDevice
530 device_object = AlexaDevice()
531 device_object._device_type = device["deviceType"]
532 device_object.device_serial_number = device["serialNumber"]
533 device_object._device_family = device["deviceOwnerCustomerId"]
534 device_object._cluster_members = device["clusterMembers"]
535 device_object._locale = alexa_locale
536 self.devices[player_id] = device_object
537
538 # Create AlexaPlayer instance
539 player = AlexaPlayer(self, player_id, device_object)
540 await self.mass.players.register_or_update(player)
541
542 await self._load_intents()
543
544 @lock
545 async def _load_intents(self) -> None:
546 """Load intents from the configured API and cache them on the provider."""
547 resp = await api_request(self, "/alexa/intents", method="GET", timeout=5)
548 data = json.loads(resp)
549 if isinstance(data, dict):
550 # cache invocationName if present
551 self._invocation_name = data.get("invocationName")
552 self._intents = data.get("intents", []) or []
553 else:
554 self._intents = []
555
556 async def get_intent_utterance(self, intent_name: str, default: str) -> str:
557 """Return the first utterance for the given intent name (cached).
558
559 If intents are not yet cached, attempt to load them.
560 """
561 if self._intents is None:
562 await self._load_intents()
563
564 for intent in self._intents or []:
565 if intent.get("intent") == intent_name:
566 utts = cast("list[str]", intent.get("utterances") or [])
567 if utts:
568 utter = utts[0]
569 if self._invocation_name:
570 inv = self._invocation_name.strip()
571 return f"{inv} {utter}".strip()
572 return utter
573 return default
574