/
/
/
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.VOLUME_SET,
271 PlayerFeature.PAUSE,
272 }
273 self._attr_name = player_id
274 self._attr_device_info = DeviceInfo()
275 self._attr_powered = False
276 self._attr_available = True
277
278 @property
279 def requires_flow_mode(self) -> bool:
280 """Return if the player requires flow mode."""
281 return True
282
283 @property
284 def api(self) -> AlexaAPI:
285 """Get the AlexaAPI instance for this player."""
286 provider = cast("AlexaProvider", self.provider)
287 return AlexaAPI(self.device, provider.login)
288
289 async def stop(self) -> None:
290 """Handle STOP command on the player."""
291 await self.api.stop()
292 self._attr_current_media = None
293 self._attr_playback_state = PlaybackState.IDLE
294 self.update_state()
295
296 async def play(self) -> None:
297 """Handle PLAY command on the player."""
298 await self.api.play()
299 self._attr_playback_state = PlaybackState.PLAYING
300 self.update_state()
301
302 async def pause(self) -> None:
303 """Handle PAUSE command on the player."""
304 await self.api.pause()
305 self._attr_playback_state = PlaybackState.PAUSED
306 self.update_state()
307
308 async def volume_set(self, volume_level: int) -> None:
309 """Handle VOLUME_SET command on the player."""
310 await self.api.set_volume(volume_level / 100)
311 self._attr_volume_level = volume_level
312 self.update_state()
313
314 async def play_media(self, media: PlayerMedia) -> None:
315 """Handle PLAY MEDIA on the player."""
316 username = self.provider.config.get_value(CONF_API_BASIC_AUTH_USERNAME)
317 password = self.provider.config.get_value(CONF_API_BASIC_AUTH_PASSWORD)
318
319 auth = None
320 if username is not None and password is not None:
321 auth = BasicAuth(str(username), str(password))
322
323 async with aiohttp.ClientSession() as session:
324 try:
325 async with session.post(
326 f"{self.provider.config.get_value(CONF_API_URL)}/ma/push-url",
327 json={
328 "streamUrl": media.uri,
329 "title": media.title,
330 "artist": media.artist,
331 "album": media.album,
332 "imageUrl": media.image_url,
333 },
334 timeout=aiohttp.ClientTimeout(total=10),
335 auth=auth,
336 ) as resp:
337 resp_text = await resp.text()
338 if resp.status < 200 or resp.status >= 300:
339 msg = (
340 f"Failed to push URL to MA Alexa API: "
341 f"Status code: {resp.status}, Response: {resp_text}. "
342 "Please verify your API connection and configuration"
343 )
344 _LOGGER.error(msg)
345 raise ActionUnavailable(msg)
346 except ActionUnavailable:
347 raise
348 except Exception as exc:
349 msg = (
350 "Failed to push URL to MA Alexa API: "
351 "Please verify your API connection and configuration"
352 )
353 _LOGGER.error("Failed to push URL to MA Alexa API: %s", exc)
354 raise ActionUnavailable(msg)
355
356 alexa_locale = self.provider.config.get_value(CONF_ALEXA_LANGUAGE)
357
358 ask_command_key = f"play_audio_{alexa_locale if alexa_locale else 'default'}"
359
360 if ask_command_key not in ALEXA_LANGUAGE_COMMANDS:
361 _LOGGER.debug(
362 "Ask command key %s not found in ALEXA_LANGUAGE_COMMANDS.",
363 ask_command_key,
364 )
365 ask_command_key = "play_audio_default"
366
367 _LOGGER.debug(
368 "Using ask command key: %s -> %s",
369 ask_command_key,
370 ALEXA_LANGUAGE_COMMANDS[ask_command_key],
371 )
372
373 await self.api.run_custom(ALEXA_LANGUAGE_COMMANDS[ask_command_key])
374 self._attr_elapsed_time = 0
375 self._attr_elapsed_time_last_updated = time.time()
376 self._attr_playback_state = PlaybackState.PLAYING
377 self._attr_current_media = media
378 self.update_state()
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 handle_async_init(self) -> None:
388 """Handle async initialization of the provider."""
389 self.devices = {}
390
391 async def loaded_in_mass(self) -> None:
392 """Call after the provider has been loaded."""
393 self.login = AlexaLogin(
394 url=str(self.config.get_value(CONF_URL)),
395 email=str(self.config.get_value(CONF_USERNAME)),
396 password=str(self.config.get_value(CONF_PASSWORD)),
397 outputpath=lambda x: x,
398 )
399
400 cookie_dir = os.path.join(self.mass.storage_path, ".alexa")
401 await asyncio.to_thread(os.makedirs, cookie_dir, exist_ok=True)
402 cookie_path = os.path.join(
403 cookie_dir, f"alexa_media.{self.config.get_value(CONF_USERNAME)}.pickle"
404 )
405 self.login._cookiefile = [self.login._outputpath(cookie_path)]
406
407 await self.login.login(cookies=await self.login.load_cookie())
408
409 devices = await AlexaAPI.get_devices(self.login)
410
411 if devices is None:
412 return
413
414 alexa_locale = str(self.config.get_value(CONF_ALEXA_LANGUAGE, "en-US"))
415
416 for device in devices:
417 if device.get("capabilities") and "MUSIC_SKILL" in device.get("capabilities"):
418 dev_name = device["accountName"]
419 player_id = dev_name
420 # Initialize AlexaDevice
421 device_object = AlexaDevice()
422 device_object._device_type = device["deviceType"]
423 device_object.device_serial_number = device["serialNumber"]
424 device_object._device_family = device["deviceOwnerCustomerId"]
425 device_object._cluster_members = device["clusterMembers"]
426 device_object._locale = alexa_locale
427 self.devices[player_id] = device_object
428
429 # Create AlexaPlayer instance
430 player = AlexaPlayer(self, player_id, device_object)
431 await self.mass.players.register_or_update(player)
432