/
/
/
1"""
2Setup flow for the Spotify Connect plugin.
3
4The flow is a single explicit backend choice: Spotify Soloist (Spotify's
5official headless client, guarded by a ToS warning/consent step and a personal
6API key) or the community go-librespot daemon. The players this plugin exposes
7Spotify Connect devices for are regular provider options, not setup input.
8Reconfigure preselects the stored backend and re-runs the branch steps;
9switching away from soloist clears the soloist secrets, but only once the new
10setup finishes successfully.
11"""
12
13from __future__ import annotations
14
15from typing import TYPE_CHECKING, Any
16
17from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
18from music_assistant_models.enums import ConfigEntryType
19
20from music_assistant.models.setup_flow import SetupFlowError
21
22from . import (
23 BACKEND_GO_LIBRESPOT,
24 BACKEND_SOLOIST,
25 CONF_API_KEY,
26 CONF_BACKEND,
27 CONF_SOLOIST_CONSENT,
28)
29from .soloist import UnsupportedPlatformError, verify_platform_supported
30
31if TYPE_CHECKING:
32 from music_assistant.models.setup_flow import SetupSession
33
34# Minimum plausible length of a pasted Soloist API key: anything shorter is a
35# partial paste. No further format rules are applied locally â Spotify rejects
36# an invalid key when soloist authenticates.
37MIN_API_KEY_LENGTH = 16
38
39
40async def run_setup(session: SetupSession) -> None:
41 """
42 Configure the Spotify Connect playback backend.
43
44 :param session: The setup session driving the flow.
45 """
46 setup_data = dict(session.context.setup_data)
47 stored_backend = str(
48 setup_data.get(CONF_BACKEND) or session.context.values.get(CONF_BACKEND) or ""
49 )
50 selected = stored_backend or BACKEND_SOLOIST
51 choice_errors: dict[str, str] | None = None
52 while True:
53 selected = await _choose_backend(session, selected, choice_errors)
54 choice_errors = None
55 # rebuild from the stored setup data each round so a refused soloist
56 # attempt does not leak partial values into a later selection
57 collected = dict(setup_data)
58 collected[CONF_BACKEND] = selected
59 if selected == BACKEND_SOLOIST:
60 if not await _run_soloist_steps(session, collected):
61 # consent refused: back to the backend choice with a clear error
62 choice_errors = {"base": "soloist_consent_required"}
63 continue
64 elif stored_backend == BACKEND_SOLOIST:
65 # switching away from soloist: overwrite the soloist secrets; they
66 # only reach the stored setup_data when finish() succeeds, so an
67 # aborted or failed switch keeps them intact
68 collected[CONF_API_KEY] = ""
69 collected[CONF_SOLOIST_CONSENT] = False
70 try:
71 await session.finish(collected)
72 return
73 except SetupFlowError as err:
74 choice_errors = {"base": err.translation_key or str(err)}
75
76
77async def _choose_backend(
78 session: SetupSession, preselect: str, errors: dict[str, str] | None
79) -> str:
80 """
81 Show the backend choice step until a usable backend is selected.
82
83 :param session: The setup session driving the flow.
84 :param preselect: Backend to preselect (the stored or previously chosen one).
85 :param errors: Optional errors to display on the first render.
86 """
87 while True:
88 values = await session.form(
89 [
90 ConfigEntry(
91 key=CONF_BACKEND,
92 type=ConfigEntryType.STRING,
93 required=True,
94 default_value=BACKEND_SOLOIST,
95 value=preselect,
96 options=[
97 ConfigValueOption(BACKEND_SOLOIST),
98 ConfigValueOption(BACKEND_GO_LIBRESPOT),
99 ],
100 expanded_options=True,
101 ),
102 ],
103 step_id="backend",
104 errors=errors,
105 )
106 selected = str(values[CONF_BACKEND])
107 if selected == BACKEND_SOLOIST:
108 try:
109 verify_platform_supported()
110 except UnsupportedPlatformError:
111 errors = {"base": "soloist_unsupported_platform"}
112 preselect = BACKEND_GO_LIBRESPOT
113 continue
114 return selected
115
116
117async def _run_soloist_steps(session: SetupSession, collected: dict[str, Any]) -> bool:
118 """
119 Run the soloist branch: the ToS warning/consent step, then the API key step.
120
121 :param session: The setup session driving the flow.
122 :param collected: The values collected so far; updated in place.
123 :return: True when the branch completed, False when consent was refused.
124 """
125 if not await _ask_consent(session, bool(collected.get(CONF_SOLOIST_CONSENT))):
126 return False
127 collected[CONF_SOLOIST_CONSENT] = True
128 await _ask_api_key(session, collected)
129 return True
130
131
132async def _ask_consent(session: SetupSession, prefill: bool) -> bool:
133 """
134 Show the soloist warning/consent step and return whether consent was given.
135
136 :param session: The setup session driving the flow.
137 :param prefill: Whether consent was already given on an earlier run.
138 """
139 values = await session.form(
140 [
141 ConfigEntry(
142 key=CONF_SOLOIST_CONSENT,
143 type=ConfigEntryType.BOOLEAN,
144 required=False,
145 default_value=False,
146 value=prefill,
147 ),
148 ],
149 step_id="soloist_terms",
150 )
151 return bool(values.get(CONF_SOLOIST_CONSENT))
152
153
154async def _ask_api_key(session: SetupSession, collected: dict[str, Any]) -> None:
155 """
156 Collect the Soloist API key.
157
158 An already stored key (reconfigure) is kept when the field is left empty;
159 it is never shown back to the user.
160
161 :param session: The setup session driving the flow.
162 :param collected: The values collected so far; updated in place.
163 """
164 has_stored_key = bool(collected.get(CONF_API_KEY))
165 errors: dict[str, str] | None = None
166 while True:
167 entries = [
168 ConfigEntry(
169 key=CONF_API_KEY,
170 type=ConfigEntryType.SECURE_STRING,
171 required=not has_stored_key,
172 ),
173 ]
174 if has_stored_key:
175 entries.insert(0, ConfigEntry(key="soloist_api_key_hint", type=ConfigEntryType.LABEL))
176 values = await session.form(entries, step_id="soloist_api_key", errors=errors)
177 api_key = str(values.get(CONF_API_KEY) or "").strip()
178 if api_key or not has_stored_key:
179 if len(api_key) < MIN_API_KEY_LENGTH:
180 errors = {CONF_API_KEY: "soloist_api_key_invalid"}
181 continue
182 collected[CONF_API_KEY] = api_key
183 return
184