/
/
1"""Setup flow for the Spotify provider."""
2
3from __future__ import annotations
4
5import logging
6from dataclasses import replace
7from typing import TYPE_CHECKING, Any
8from urllib.parse import urlencode
9
10import pkce
11from aiohttp import ClientError, ClientTimeout
12from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
13from music_assistant_models.enums import ConfigEntryType
14from music_assistant_models.errors import LoginFailed
15
16from music_assistant.helpers.app_vars import app_var
17from music_assistant.helpers.json import json_loads
18from music_assistant.helpers.oauth import (
19 HOSTED_CALLBACK_URL,
20 OAUTH_STEP_TIMEOUT,
21 authorization_code_from_params,
22 authorization_code_from_url,
23 hosted_bounce_redirect,
24)
25from music_assistant.models.setup_flow import AbortFlow, SetupFlowError, StepExpiredError
26
27from .constants import (
28 CONF_ACCOUNT_ID,
29 CONF_CLIENT_ID,
30 CONF_LIBRESPOT_CREDENTIALS,
31 CONF_REFRESH_TOKEN_DEV,
32 CONF_REFRESH_TOKEN_GLOBAL,
33 KEYMASTER_CLIENT_ID,
34 LIBRESPOT_REDIRECT_PATH,
35 LIBRESPOT_REDIRECT_PORT,
36 LIBRESPOT_REDIRECT_URI,
37 LIBRESPOT_SCOPE,
38 LOOPBACK_WAIT_TIMEOUT,
39 PAIRING_DEVICE_NAME,
40 PAIRING_TIMEOUT,
41 SCOPE,
42)
43from .helpers import (
44 await_loopback_authorization,
45 get_librespot_binary,
46 librespot_credentials_via_pairing,
47 librespot_credentials_via_token,
48)
49from .provider import SpotifyProvider
50
51if TYPE_CHECKING:
52 from music_assistant.models.setup_flow import SetupSession
53
54LOGGER = logging.getLogger(__name__)
55
56# seconds to wait for the account lookup that gates the setup
57ACCOUNT_LOOKUP_TIMEOUT = 30
58
59AUTHORIZE_URL = "https://accounts.spotify.com/authorize"
60TOKEN_URL = "https://accounts.spotify.com/api/token"
61
62# the developer client id is a public OAuth identifier (not a secret), so it is a plain
63# STRING that can be prefilled on reconfigure
64CONF_ENTRY_DEV_CLIENT_ID = ConfigEntry(
65 key=CONF_CLIENT_ID,
66 type=ConfigEntryType.STRING,
67 required=False,
68)
69
70CONF_USE_DEV_KEY = "use_developer_key"
71CONF_ENTRY_USE_DEV_KEY = ConfigEntry(
72 key=CONF_USE_DEV_KEY,
73 type=ConfigEntryType.BOOLEAN,
74 default_value=False,
75 required=False,
76)
77
78CONF_PLAYBACK_AUTH_METHOD = "playback_auth_method"
79PLAYBACK_AUTH_APP = "spotify_app"
80PLAYBACK_AUTH_BROWSER = "browser"
81CONF_ENTRY_PLAYBACK_AUTH_METHOD = ConfigEntry(
82 key=CONF_PLAYBACK_AUTH_METHOD,
83 type=ConfigEntryType.STRING,
84 default_value=PLAYBACK_AUTH_APP,
85 options=[ConfigValueOption(PLAYBACK_AUTH_APP), ConfigValueOption(PLAYBACK_AUTH_BROWSER)],
86)
87
88CONF_PLAYBACK_CALLBACK_URL = "playback_callback_url"
89CONF_ENTRY_PLAYBACK_CALLBACK_URL = ConfigEntry(
90 key=CONF_PLAYBACK_CALLBACK_URL,
91 type=ConfigEntryType.STRING,
92)
93
94
95async def run_setup(session: SetupSession) -> None:
96 """
97 Run the Spotify setup flow.
98
99 Authenticates the (required) global session with Music Assistant's own client id, authorizes
100 playback separately, then optionally a developer session with the user's own client id, and
101 persists the resulting tokens and credentials as setup data.
102
103 :param session: The setup session driving the flow.
104 """
105 setup_data = dict(session.context.setup_data)
106 # the global session always (re)authenticates: a refresh token cannot be reused across a
107 # re-auth and secure values are never prefilled back into the flow
108 token_result = await _pkce_authenticate(
109 session, app_var("spotify_client_id"), step_id="authenticate"
110 )
111 setup_data[CONF_REFRESH_TOKEN_GLOBAL] = str(token_result["refresh_token"])
112 # an account that cannot work is turned away before the playback authorization
113 account_id = await _verify_account(session, str(token_result["access_token"]))
114 setup_data[CONF_ACCOUNT_ID] = account_id
115 # playback needs its own credential, minted with Spotify's keymaster client id
116 setup_data[CONF_LIBRESPOT_CREDENTIALS] = await _authorize_playback(session, account_id)
117 # everything needed is collected by now; the developer key is a purely optional extra,
118 # so it is offered as an opt-in rather than a field the user has to reason about
119 client_id_default = str(session.context.setup_data.get(CONF_CLIENT_ID) or "")
120 errors: dict[str, str] | None = None
121 while True:
122 optin_values = await session.form(
123 [replace(CONF_ENTRY_USE_DEV_KEY, value=bool(client_id_default))],
124 step_id="developer_optin",
125 errors=errors,
126 last_step=True,
127 )
128 if not optin_values.get(CONF_USE_DEV_KEY):
129 # opted out: clear any previously stored developer session
130 setup_data[CONF_CLIENT_ID] = None
131 setup_data[CONF_REFRESH_TOKEN_DEV] = None
132 try:
133 await session.finish(setup_data)
134 return
135 except SetupFlowError as err:
136 errors = {"base": err.translation_key or str(err)}
137 continue
138 client_id_default, errors = await _authorize_developer_key(
139 session, setup_data, client_id_default
140 )
141 if errors is None:
142 return
143
144
145async def _authorize_developer_key(
146 session: SetupSession, setup_data: dict[str, Any], client_id_default: str
147) -> tuple[str, dict[str, str] | None]:
148 """
149 Collect and authorize the user's own Spotify developer key, then finish the flow.
150
151 Returns the client id to prefill and the errors to show when the attempt failed; the
152 errors are None once the flow has finished.
153
154 :param session: The setup session driving the flow.
155 :param setup_data: The setup data collected so far, updated in place.
156 :param client_id_default: Client id to prefill in the form.
157 """
158 dev_values = await session.form(
159 [replace(CONF_ENTRY_DEV_CLIENT_ID, value=client_id_default)],
160 step_id="developer",
161 last_step=True,
162 translation_params=[HOSTED_CALLBACK_URL],
163 )
164 client_id = str(dev_values.get(CONF_CLIENT_ID) or "").strip()
165 try:
166 if client_id:
167 setup_data[CONF_CLIENT_ID] = client_id
168 dev_token_result = await _pkce_authenticate(
169 session, client_id, step_id="authenticate_dev"
170 )
171 setup_data[CONF_REFRESH_TOKEN_DEV] = str(dev_token_result["refresh_token"])
172 else:
173 # opted in but left the field empty: keep using the shared key
174 setup_data[CONF_CLIENT_ID] = None
175 setup_data[CONF_REFRESH_TOKEN_DEV] = None
176 await session.finish(setup_data)
177 except SetupFlowError as err:
178 return client_id, {"base": err.translation_key or str(err)}
179 return client_id, None
180
181
182async def _verify_account(session: SetupSession, access_token: str) -> str | None:
183 """
184 Check the just-authenticated Spotify account and return its id.
185
186 Turns the user away when the account has no Spotify Premium (librespot, which
187 streams this provider's audio, refuses to play for a free account) or when it is
188 already set up on another provider instance. A lookup Spotify does not answer is
189 not held against the user: the setup simply continues and None is returned.
190
191 :param session: The setup session driving the flow.
192 :param access_token: The access token from the just-completed sign-in. Reusing
193 it is deliberate — minting a fresh one rotates the refresh token, which
194 revokes the one just stored as setup data.
195 :raises AbortFlow: When the account is non-Premium or already configured.
196 """
197 try:
198 async with session.mass.http_session.get(
199 "https://api.spotify.com/v1/me",
200 headers={"Authorization": f"Bearer {access_token}"},
201 timeout=ClientTimeout(total=ACCOUNT_LOOKUP_TIMEOUT),
202 ) as response:
203 if response.status != 200:
204 LOGGER.warning("Account check skipped: Spotify replied HTTP %s", response.status)
205 return None
206 # a malformed body raises ValueError, which is not a ClientError
207 userinfo = await response.json()
208 except (ClientError, TimeoutError, ValueError) as err:
209 # a bare TimeoutError stringifies to nothing, so log the type too
210 LOGGER.warning("Account check skipped: %s %s", type(err).__name__, err)
211 return None
212 if not isinstance(userinfo, dict):
213 LOGGER.warning("Account check skipped: Spotify returned an unexpected profile")
214 return None
215 product = str(userinfo.get("product") or "")
216 if product and product != "premium":
217 raise AbortFlow("premium_required")
218 if not (account_id := str(userinfo.get("id") or "")):
219 return None
220 if await _account_in_use(session, account_id):
221 raise AbortFlow("account_already_configured")
222 return account_id
223
224
225async def _account_in_use(session: SetupSession, account_id: str) -> bool:
226 """
227 Return whether another Spotify provider instance is already set up for this account.
228
229 Compares the account id stored with each instance's configuration, so an instance
230 that is disabled or failed to load still holds its account. Configurations
231 predating that stored value fall back to the running instance, which fills the
232 value in on its next successful load. The instance being reconfigured is of
233 course allowed to keep its own account.
234
235 :param session: The setup session driving the flow.
236 :param account_id: The Spotify user id that just signed in.
237 """
238 mass = session.mass
239 for config in await mass.config.get_provider_configs(provider_domain="spotify"):
240 if config.instance_id == session.context.instance_id:
241 continue
242 if stored := mass.config.get_provider_setup_value(config.instance_id, CONF_ACCOUNT_ID):
243 if str(stored) == account_id:
244 return True
245 continue
246 provider = mass.get_provider(config.instance_id, return_unavailable=True)
247 if isinstance(provider, SpotifyProvider) and provider.account_id == account_id:
248 return True
249 return False
250
251
252async def _authorize_playback(session: SetupSession, account_id: str | None) -> str:
253 """
254 Obtain librespot's playback credential and return it as stored-credential JSON.
255
256 Offers pairing through the Spotify app first and falls back to a browser sign-in for
257 setups where the Spotify app cannot discover Music Assistant. The credential has to
258 belong to the account that signed in: authorizing playback from a Spotify app that
259 is logged in as someone else would leave the library and the audio on different
260 accounts.
261
262 :param session: The setup session driving the flow.
263 :param account_id: The signed-in Spotify user id to match the credential against;
264 the check is skipped when it (or the credential's own account) is unknown.
265 """
266 try:
267 librespot_bin = await get_librespot_binary()
268 except RuntimeError as err:
269 raise SetupFlowError(str(err), translation_key="librespot_unavailable") from err
270 errors: dict[str, str] | None = None
271 while True:
272 method_values = await session.form(
273 [CONF_ENTRY_PLAYBACK_AUTH_METHOD],
274 step_id="playback_auth",
275 errors=errors,
276 )
277 method = str(method_values.get(CONF_PLAYBACK_AUTH_METHOD) or PLAYBACK_AUTH_APP)
278 # every failure loops back to this form: the account is already authorized by now, so
279 # aborting the flow would throw that away over a retryable mistake
280 try:
281 if method == PLAYBACK_AUTH_APP:
282 credentials = await session.progress_until(
283 librespot_credentials_via_pairing(librespot_bin, PAIRING_DEVICE_NAME),
284 step_id="playback_pairing",
285 text="pairing_instructions",
286 expires_in=PAIRING_TIMEOUT,
287 )
288 else:
289 credentials = await _authorize_playback_via_browser(session, librespot_bin)
290 if _credential_account_differs(credentials, account_id):
291 errors = {"base": "playback_account_mismatch"}
292 continue
293 return credentials
294 except StepExpiredError:
295 errors = {
296 "base": "pairing_not_completed"
297 if method == PLAYBACK_AUTH_APP
298 else "playback_not_completed"
299 }
300 except SetupFlowError as err:
301 errors = {"base": err.translation_key or "playback_auth_failed"}
302 except LoginFailed, ClientError, KeyError:
303 # librespot refusing the token, a transport failure, or a token response without a
304 # token; LoginFailed's own default key is too generic to show here
305 errors = {"base": "playback_auth_failed"}
306
307
308def _credential_account_differs(credentials: str, account_id: str | None) -> bool:
309 """
310 Return whether a playback credential belongs to a different account than the sign-in.
311
312 Answers False whenever either side is unknown, so an unreadable credential never
313 blocks a setup that is otherwise fine.
314
315 :param credentials: librespot's stored-credential JSON.
316 :param account_id: The signed-in Spotify user id, when known.
317 """
318 if not account_id:
319 return False
320 try:
321 stored = json_loads(credentials)
322 except ValueError:
323 return False
324 if not isinstance(stored, dict):
325 return False
326 # librespot stores Spotify's canonical username, which is the signed-in id lowercased
327 username = str(stored.get("username") or "")
328 if not username or username.casefold() == account_id.casefold():
329 return False
330 LOGGER.warning("Playback was authorized for %s instead of %s", username, account_id)
331 return True
332
333
334async def _authorize_playback_via_browser(session: SetupSession, librespot_bin: str) -> str:
335 """
336 Run the keymaster sign-in and return librespot's stored credential.
337
338 Spotify only accepts a loopback redirect for this client id, so the browser cannot report
339 back to Music Assistant: the user copies the URL their browser ended up on instead.
340
341 :param session: The setup session driving the flow.
342 :param librespot_bin: Path to the librespot binary.
343 """
344 code_verifier, code_challenge = pkce.generate_pkce_pair()
345 params = {
346 "response_type": "code",
347 "client_id": KEYMASTER_CLIENT_ID,
348 "scope": " ".join(LIBRESPOT_SCOPE),
349 "code_challenge_method": "S256",
350 "code_challenge": code_challenge,
351 "redirect_uri": LIBRESPOT_REDIRECT_URI,
352 }
353 authorize_url = f"{AUTHORIZE_URL}?{urlencode(params)}"
354 try:
355 # the loopback target is only reachable when the browser runs on this host, in which
356 # case the step completes on its own; everyone else falls through to the paste form
357 callback_params = await session.external_until(
358 await_loopback_authorization(LIBRESPOT_REDIRECT_PORT, LIBRESPOT_REDIRECT_PATH),
359 authorize_url,
360 step_id="playback_browser_open",
361 expires_in=LOOPBACK_WAIT_TIMEOUT,
362 )
363 code = authorization_code_from_params(callback_params)
364 except StepExpiredError, OSError:
365 values = await session.form(
366 [CONF_ENTRY_PLAYBACK_CALLBACK_URL],
367 step_id="playback_browser",
368 expires_in=OAUTH_STEP_TIMEOUT,
369 translation_params=[authorize_url],
370 )
371 code = authorization_code_from_url(str(values.get(CONF_PLAYBACK_CALLBACK_URL) or ""))
372 token_params = {
373 "grant_type": "authorization_code",
374 "code": code,
375 "redirect_uri": LIBRESPOT_REDIRECT_URI,
376 "client_id": KEYMASTER_CLIENT_ID,
377 "code_verifier": code_verifier,
378 }
379 async with session.mass.http_session.post(TOKEN_URL, data=token_params) as response:
380 if response.status != 200:
381 raise SetupFlowError(
382 f"Failed to get access token: {await response.text()}",
383 translation_key="playback_code_invalid",
384 )
385 token_result = await response.json()
386 return await librespot_credentials_via_token(librespot_bin, token_result["access_token"])
387
388
389async def _pkce_authenticate(session: SetupSession, client_id: str, step_id: str) -> dict[str, Any]:
390 """
391 Run the Spotify PKCE auth flow and return the token result (refresh + access token).
392
393 :param session: The setup session driving the flow.
394 :param client_id: The Spotify client id to authenticate with.
395 :param step_id: The external step id (also the i18n key segment).
396 """
397 code_verifier, code_challenge = pkce.generate_pkce_pair()
398 redirect_uri, state = hosted_bounce_redirect(session.callback_url)
399 params = {
400 "response_type": "code",
401 "client_id": client_id,
402 "scope": " ".join(SCOPE),
403 "code_challenge_method": "S256",
404 "code_challenge": code_challenge,
405 "redirect_uri": redirect_uri,
406 "state": state,
407 }
408 callback_params = await session.external(
409 f"{AUTHORIZE_URL}?{urlencode(params)}", step_id=step_id, expires_in=OAUTH_STEP_TIMEOUT
410 )
411 code = authorization_code_from_params(callback_params)
412 token_params = {
413 "grant_type": "authorization_code",
414 "code": code,
415 "redirect_uri": redirect_uri,
416 "client_id": client_id,
417 "code_verifier": code_verifier,
418 }
419 async with session.mass.http_session.post(TOKEN_URL, data=token_params) as response:
420 if response.status != 200:
421 raise SetupFlowError(f"Failed to get access token: {await response.text()}")
422 token_result: dict[str, Any] = await response.json()
423 if not token_result.get("refresh_token"):
424 raise SetupFlowError("No refresh token in the token response")
425 return token_result
426