/
/
/
1"""Alexa player provider support for Music Assistant."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7import os
8import time
9from typing import TYPE_CHECKING, Any, cast
10
11import aiohttp
12from aiohttp import BasicAuth, web
13from alexapy import AlexaAPI, AlexaLogin, AlexaProxy
14from music_assistant_models.config_entries import ConfigEntry
15from music_assistant_models.enums import (
16 ConfigEntryType,
17 PlaybackState,
18 PlayerFeature,
19 ProviderFeature,
20)
21from music_assistant_models.errors import ActionUnavailable, LoginFailed
22from music_assistant_models.player import DeviceInfo, PlayerMedia
23
24from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
25from music_assistant.helpers.auth import AuthenticationHelper
26from music_assistant.models.player import Player
27from music_assistant.models.player_provider import PlayerProvider
28
29_LOGGER = logging.getLogger(__name__)
30
31if TYPE_CHECKING:
32 from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
33 from music_assistant_models.provider import ProviderManifest
34
35 from music_assistant.mass import MusicAssistant
36 from music_assistant.models import ProviderInstanceType
37
38CONF_URL = "url"
39CONF_ACTION_AUTH = "auth"
40CONF_AUTH_SECRET = "secret"
41CONF_API_BASIC_AUTH_USERNAME = "api_username"
42CONF_API_BASIC_AUTH_PASSWORD = "api_password"
43CONF_API_URL = "api_url"
44CONF_ALEXA_LANGUAGE = "alexa_language"
45
46ALEXA_LANGUAGE_COMMANDS = {
47 "play_audio_de-DE": "sag music assistant spiele audio",
48 "play_audio_en-US": "ask music assistant to play audio",
49 "play_audio_es-ES": "pÃdele a music assistant que reproduzca audio",
50 "play_audio_fr-FR": "music assistant",
51 "play_audio_it-IT": "chiedi a music assistant di riprodurre audio",
52 "play_audio_default": "ask music assistant to play audio",
53}
54
55SUPPORTED_FEATURES: set[ProviderFeature] = set() # no special features supported (yet)
56
57
58async def setup(
59 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
60) -> ProviderInstanceType:
61 """Initialize provider(instance) with given configuration."""
62 return AlexaProvider(mass, manifest, config, SUPPORTED_FEATURES)
63
64
65async def get_config_entries(
66 mass: MusicAssistant,
67 instance_id: str | None = None,
68 action: str | None = None,
69 values: dict[str, ConfigValueType] | None = None,
70) -> tuple[ConfigEntry, ...]:
71 """
72 Return Config entries to setup this provider.
73
74 instance_id: id of an existing provider instance (None if new instance setup).
75 action: [optional] action key called from config entries UI.
76 values: the (intermediate) raw values for config entries sent with the action.
77 """
78 # ruff: noqa: ARG001
79 # config flow auth action/step (authenticate button clicked)
80 if action == CONF_ACTION_AUTH and values:
81 async with AuthenticationHelper(mass, str(values["session_id"])) as auth_helper:
82 login = AlexaLogin(
83 url=str(values[CONF_URL]),
84 email=str(values[CONF_USERNAME]),
85 password=str(values[CONF_PASSWORD]),
86 otp_secret=str(values.get(CONF_AUTH_SECRET, "")),
87 outputpath=lambda x: x,
88 )
89
90 # --- Proxy authentication logic using AlexaProxy ---
91 # Build the proxy path and URL
92 proxy_path = "/alexa/auth/proxy/"
93 post_path = "/alexa/auth/proxy/ap/signin/*"
94 base_url = mass.webserver.base_url.rstrip("/")
95 proxy_url = f"{base_url}{proxy_path}"
96
97 # Create AlexaProxy instance
98 proxy = AlexaProxy(login, proxy_url)
99
100 # Handler that delegates to AlexaProxy's all_handler
101 async def proxy_handler(request: web.Request) -> Any:
102 response = await proxy.all_handler(request)
103 if "Successfully logged in" in getattr(response, "text", ""):
104 # Notify the callback URL
105 async with aiohttp.ClientSession() as session:
106 await session.get(auth_helper.callback_url)
107 _LOGGER.info("Alexa Callback URL: %s", auth_helper.callback_url)
108 return web.Response(
109 text="""
110 <html>
111 <body>
112 <h2>Login successful!</h2>
113 <p>You may now close this window.</p>
114 </body>
115 </html>
116 """,
117 content_type="text/html",
118 )
119 return response
120
121 # Register GET for the base proxy path
122 mass.webserver.register_dynamic_route(proxy_path, proxy_handler, "GET")
123 # Register POST for the specific signin helper path
124 mass.webserver.register_dynamic_route(post_path, proxy_handler, "POST")
125
126 try:
127 await auth_helper.authenticate(proxy_url)
128 if await login.test_loggedin():
129 await save_cookie(login, str(values[CONF_USERNAME]), mass)
130 else:
131 raise LoginFailed(
132 "Authentication login failed, please provide logs to the discussion #431."
133 )
134 except KeyError:
135 # no URL param was found so user probably cancelled the auth
136 pass
137 except Exception as error:
138 raise LoginFailed(f"Failed to authenticate with Amazon '{error}'.")
139 finally:
140 mass.webserver.unregister_dynamic_route(proxy_path, "GET")
141 mass.webserver.unregister_dynamic_route(post_path, "POST")
142
143 return (
144 ConfigEntry(
145 key=CONF_URL,
146 type=ConfigEntryType.STRING,
147 label="URL",
148 required=True,
149 default_value="amazon.com",
150 ),
151 ConfigEntry(
152 key=CONF_USERNAME,
153 type=ConfigEntryType.STRING,
154 label="E-Mail",
155 required=True,
156 value=values.get(CONF_USERNAME) if values else None,
157 ),
158 ConfigEntry(
159 key=CONF_PASSWORD,
160 type=ConfigEntryType.SECURE_STRING,
161 label="Password",
162 required=True,
163 value=values.get(CONF_PASSWORD) if values else None,
164 ),
165 ConfigEntry(
166 key=CONF_AUTH_SECRET,
167 type=ConfigEntryType.SECURE_STRING,
168 label="OTP Secret",
169 required=False,
170 value=values.get(CONF_AUTH_SECRET) if values else None,
171 ),
172 ConfigEntry(
173 key=CONF_ACTION_AUTH,
174 type=ConfigEntryType.ACTION,
175 label="Authenticate with Amazon",
176 description="Click to start the authentication process.",
177 action=CONF_ACTION_AUTH,
178 depends_on=CONF_URL,
179 ),
180 ConfigEntry(
181 key=CONF_API_URL,
182 type=ConfigEntryType.STRING,
183 label="API Url",
184 default_value="http://localhost:3000",
185 required=True,
186 value=values.get(CONF_API_URL) if values else None,
187 ),
188 ConfigEntry(
189 key=CONF_API_BASIC_AUTH_USERNAME,
190 type=ConfigEntryType.STRING,
191 label="API Basic Auth Username",
192 required=False,
193 value=values.get(CONF_API_BASIC_AUTH_USERNAME) if values else None,
194 ),
195 ConfigEntry(
196 key=CONF_API_BASIC_AUTH_PASSWORD,
197 type=ConfigEntryType.SECURE_STRING,
198 label="API Basic Auth Password",
199 required=False,
200 value=values.get(CONF_API_BASIC_AUTH_PASSWORD) if values else None,
201 ),
202 ConfigEntry(
203 key=CONF_ALEXA_LANGUAGE,
204 type=ConfigEntryType.STRING,
205 label="Alexa Language",
206 required=True,
207 default_value="en-US",
208 ),
209 )
210
211
212async def save_cookie(login: AlexaLogin, username: str, mass: MusicAssistant) -> None:
213 """Save the cookie file for the Alexa login."""
214 if login._session is None:
215 _LOGGER.error("AlexaLogin session is not initialized.")
216 return
217
218 cookie_dir = os.path.join(mass.storage_path, ".alexa")
219 await asyncio.to_thread(os.makedirs, cookie_dir, exist_ok=True)
220 cookie_path = os.path.join(cookie_dir, f"alexa_media.{username}.pickle")
221 login._cookiefile = [login._outputpath(cookie_path)]
222 if (login._cookiefile[0]) and await asyncio.to_thread(os.path.exists, login._cookiefile[0]):
223 _LOGGER.debug("Removing outdated cookiefile %s", login._cookiefile[0])
224 await delete_cookie(login._cookiefile[0])
225 cookie_jar = login._session.cookie_jar
226 assert isinstance(cookie_jar, aiohttp.CookieJar)
227 if login._debug:
228 _LOGGER.debug("Saving cookie to %s", login._cookiefile[0])
229 try:
230 await asyncio.to_thread(cookie_jar.save, login._cookiefile[0])
231 except (OSError, EOFError, TypeError, AttributeError):
232 _LOGGER.debug("Error saving pickled cookie to %s", login._cookiefile[0])
233
234
235async def delete_cookie(cookiefile: str) -> None:
236 """Delete the specified cookie file."""
237 if await asyncio.to_thread(os.path.exists, cookiefile):
238 try:
239 await asyncio.to_thread(os.remove, cookiefile)
240 _LOGGER.debug("Deleted cookie file: %s", cookiefile)
241 except OSError as e:
242 _LOGGER.error("Failed to delete cookie file %s: %s", cookiefile, e)
243 else:
244 _LOGGER.debug("Cookie file %s does not exist, nothing to delete.", cookiefile)
245
246
247class AlexaDevice:
248 """Representation of an Alexa Device."""
249
250 _device_type: str
251 device_serial_number: str
252 _device_family: str
253 _cluster_members: str
254 _locale: str
255
256
257class AlexaPlayer(Player):
258 """Implementation of an Alexa Player."""
259
260 def __init__(
261 self,
262 provider: AlexaProvider,
263 player_id: str,
264 device: AlexaDevice,
265 ) -> None:
266 """Initialize AlexaPlayer."""
267 super().__init__(provider, player_id)
268 self.device = device
269 self._attr_supported_features = {
270 PlayerFeature.PLAY_MEDIA,
271 PlayerFeature.VOLUME_SET,
272 PlayerFeature.PAUSE,
273 }
274 self._attr_name = player_id
275 self._attr_device_info = DeviceInfo()
276 self._attr_powered = False
277 self._attr_available = True
278
279 @property
280 def requires_flow_mode(self) -> bool:
281 """Return if the player requires flow mode."""
282 return True
283
284 @property
285 def api(self) -> AlexaAPI:
286 """Get the AlexaAPI instance for this player."""
287 provider = cast("AlexaProvider", self.provider)
288 return AlexaAPI(self.device, provider.login)
289
290 async def stop(self) -> None:
291 """Handle STOP command on the player."""
292 await self.api.stop()
293 self._attr_current_media = None
294 self._attr_playback_state = PlaybackState.IDLE
295 self.update_state()
296
297 async def play(self) -> None:
298 """Handle PLAY command on the player."""
299 await self.api.play()
300 self._attr_playback_state = PlaybackState.PLAYING
301 self.update_state()
302
303 async def pause(self) -> None:
304 """Handle PAUSE command on the player."""
305 await self.api.pause()
306 self._attr_playback_state = PlaybackState.PAUSED
307 self.update_state()
308
309 async def volume_set(self, volume_level: int) -> None:
310 """Handle VOLUME_SET command on the player."""
311 await self.api.set_volume(volume_level / 100)
312 self._attr_volume_level = volume_level
313 self.update_state()
314
315 async def play_media(self, media: PlayerMedia) -> None:
316 """Handle PLAY MEDIA on the player."""
317 username = self.provider.config.get_value(CONF_API_BASIC_AUTH_USERNAME)
318 password = self.provider.config.get_value(CONF_API_BASIC_AUTH_PASSWORD)
319
320 auth = None
321 if username is not None and password is not None:
322 auth = BasicAuth(str(username), str(password))
323
324 stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
325
326 async with aiohttp.ClientSession() as session:
327 try:
328 async with session.post(
329 f"{self.provider.config.get_value(CONF_API_URL)}/ma/push-url",
330 json={
331 "streamUrl": stream_url,
332 "title": media.title,
333 "artist": media.artist,
334 "album": media.album,
335 "imageUrl": media.image_url,
336 },
337 timeout=aiohttp.ClientTimeout(total=10),
338 auth=auth,
339 ) as resp:
340 resp_text = await resp.text()
341 if resp.status < 200 or resp.status >= 300:
342 msg = (
343 f"Failed to push URL to MA Alexa API: "
344 f"Status code: {resp.status}, Response: {resp_text}. "
345 "Please verify your API connection and configuration"
346 )
347 _LOGGER.error(msg)
348 raise ActionUnavailable(msg)
349 except ActionUnavailable:
350 raise
351 except Exception as exc:
352 msg = (
353 "Failed to push URL to MA Alexa API: "
354 "Please verify your API connection and configuration"
355 )
356 _LOGGER.error("Failed to push URL to MA Alexa API: %s", exc)
357 raise ActionUnavailable(msg)
358
359 alexa_locale = self.provider.config.get_value(CONF_ALEXA_LANGUAGE)
360
361 ask_command_key = f"play_audio_{alexa_locale if alexa_locale else 'default'}"
362
363 if ask_command_key not in ALEXA_LANGUAGE_COMMANDS:
364 _LOGGER.debug(
365 "Ask command key %s not found in ALEXA_LANGUAGE_COMMANDS.",
366 ask_command_key,
367 )
368 ask_command_key = "play_audio_default"
369
370 _LOGGER.debug(
371 "Using ask command key: %s -> %s",
372 ask_command_key,
373 ALEXA_LANGUAGE_COMMANDS[ask_command_key],
374 )
375
376 await self.api.run_custom(ALEXA_LANGUAGE_COMMANDS[ask_command_key])
377 self._attr_elapsed_time = 0
378 self._attr_elapsed_time_last_updated = time.time()
379 self._attr_playback_state = PlaybackState.PLAYING
380 self._attr_current_media = media
381 self.update_state()
382
383
384class AlexaProvider(PlayerProvider):
385 """Implementation of an Alexa Device Provider."""
386
387 login: AlexaLogin
388 devices: dict[str, AlexaDevice]
389
390 async def handle_async_init(self) -> None:
391 """Handle async initialization of the provider."""
392 self.devices = {}
393
394 async def loaded_in_mass(self) -> None:
395 """Call after the provider has been loaded."""
396 self.login = AlexaLogin(
397 url=str(self.config.get_value(CONF_URL)),
398 email=str(self.config.get_value(CONF_USERNAME)),
399 password=str(self.config.get_value(CONF_PASSWORD)),
400 outputpath=lambda x: x,
401 )
402
403 cookie_dir = os.path.join(self.mass.storage_path, ".alexa")
404 await asyncio.to_thread(os.makedirs, cookie_dir, exist_ok=True)
405 cookie_path = os.path.join(
406 cookie_dir, f"alexa_media.{self.config.get_value(CONF_USERNAME)}.pickle"
407 )
408 self.login._cookiefile = [self.login._outputpath(cookie_path)]
409
410 await self.login.login(cookies=await self.login.load_cookie())
411
412 devices = await AlexaAPI.get_devices(self.login)
413
414 if devices is None:
415 return
416
417 alexa_locale = str(self.config.get_value(CONF_ALEXA_LANGUAGE, "en-US"))
418
419 for device in devices:
420 if device.get("capabilities") and "MUSIC_SKILL" in device.get("capabilities"):
421 dev_name = device["accountName"]
422 player_id = dev_name
423 # Initialize AlexaDevice
424 device_object = AlexaDevice()
425 device_object._device_type = device["deviceType"]
426 device_object.device_serial_number = device["serialNumber"]
427 device_object._device_family = device["deviceOwnerCustomerId"]
428 device_object._cluster_members = device["clusterMembers"]
429 device_object._locale = alexa_locale
430 self.devices[player_id] = device_object
431
432 # Create AlexaPlayer instance
433 player = AlexaPlayer(self, player_id, device_object)
434 await self.mass.players.register_or_update(player)
435