/
/
/
1"""Spotify music provider support for Music Assistant."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING, cast
6
7from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
8from music_assistant_models.enums import ConfigEntryType, ProviderFeature
9from music_assistant_models.errors import InvalidDataError, LoginFailed
10
11from music_assistant.helpers.app_vars import app_var # type: ignore[attr-defined]
12
13from .constants import (
14 CALLBACK_REDIRECT_URL,
15 CONF_ACTION_AUTH,
16 CONF_ACTION_AUTH_DEV,
17 CONF_ACTION_CLEAR_AUTH,
18 CONF_ACTION_CLEAR_AUTH_DEV,
19 CONF_CLIENT_ID,
20 CONF_REFRESH_TOKEN_DEPRECATED,
21 CONF_REFRESH_TOKEN_DEV,
22 CONF_REFRESH_TOKEN_GLOBAL,
23 CONF_SYNC_AUDIOBOOK_PROGRESS,
24 CONF_SYNC_PODCAST_PROGRESS,
25)
26from .helpers import pkce_auth_flow
27from .provider import SpotifyProvider
28
29if TYPE_CHECKING:
30 from music_assistant_models.config_entries import ProviderConfig
31 from music_assistant_models.provider import ProviderManifest
32
33 from music_assistant import MusicAssistant
34 from music_assistant.models import ProviderInstanceType
35
36SUPPORTED_FEATURES = {
37 ProviderFeature.LIBRARY_ARTISTS,
38 ProviderFeature.LIBRARY_ALBUMS,
39 ProviderFeature.LIBRARY_TRACKS,
40 ProviderFeature.LIBRARY_PLAYLISTS,
41 ProviderFeature.LIBRARY_ARTISTS_EDIT,
42 ProviderFeature.LIBRARY_ALBUMS_EDIT,
43 ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
44 ProviderFeature.LIBRARY_TRACKS_EDIT,
45 ProviderFeature.PLAYLIST_TRACKS_EDIT,
46 ProviderFeature.BROWSE,
47 ProviderFeature.SEARCH,
48 ProviderFeature.ARTIST_ALBUMS,
49 ProviderFeature.ARTIST_TOPTRACKS,
50 ProviderFeature.SIMILAR_TRACKS,
51 ProviderFeature.LIBRARY_PODCASTS,
52 ProviderFeature.LIBRARY_PODCASTS_EDIT,
53}
54
55
56async def _handle_auth_actions(
57 mass: MusicAssistant,
58 action: str | None,
59 values: dict[str, ConfigValueType] | None,
60) -> None:
61 """Handle authentication-related actions for config entries."""
62 if values is None:
63 return
64
65 if action == CONF_ACTION_AUTH:
66 refresh_token = await pkce_auth_flow(mass, cast("str", values["session_id"]), app_var(2))
67 values[CONF_REFRESH_TOKEN_GLOBAL] = refresh_token
68 values[CONF_REFRESH_TOKEN_DEV] = None # Clear dev token on new global auth
69 values[CONF_CLIENT_ID] = None # Clear client ID on new global auth
70
71 elif action == CONF_ACTION_AUTH_DEV:
72 custom_client_id = values.get(CONF_CLIENT_ID)
73 if not custom_client_id:
74 raise InvalidDataError("Client ID is required for developer authentication")
75 refresh_token = await pkce_auth_flow(
76 mass, cast("str", values["session_id"]), cast("str", custom_client_id)
77 )
78 values[CONF_REFRESH_TOKEN_DEV] = refresh_token
79
80 elif action == CONF_ACTION_CLEAR_AUTH:
81 values[CONF_REFRESH_TOKEN_GLOBAL] = None
82
83 elif action == CONF_ACTION_CLEAR_AUTH_DEV:
84 values[CONF_REFRESH_TOKEN_DEV] = None
85 values[CONF_CLIENT_ID] = None
86
87
88async def get_config_entries(
89 mass: MusicAssistant,
90 instance_id: str | None = None,
91 action: str | None = None,
92 values: dict[str, ConfigValueType] | None = None,
93) -> tuple[ConfigEntry, ...]:
94 """Return Config entries to setup this provider."""
95 # Check if audiobooks are supported by existing provider instance
96 audiobooks_supported = (
97 instance_id
98 and (prov_instance := mass.get_provider(instance_id))
99 and getattr(prov_instance, "audiobooks_supported", False)
100 )
101
102 # Handle any authentication actions
103 await _handle_auth_actions(mass, action, values)
104
105 # Determine authentication states from current values
106 # Note: encrypted values are sent as placeholder text, which indicates value IS set
107 global_token = (values or {}).get(CONF_REFRESH_TOKEN_GLOBAL)
108 dev_token = (values or {}).get(CONF_REFRESH_TOKEN_DEV)
109 global_authenticated = global_token not in (None, "")
110 dev_authenticated = dev_token not in (None, "")
111
112 # Build label text based on state - these are dynamic based on current values
113 if not global_authenticated:
114 label_text = (
115 "You need to authenticate to Spotify. Click the authenticate button below "
116 "to start the authentication process which will open in a new (popup) window, "
117 "so make sure to disable any popup blockers.\n\n"
118 "Also make sure to perform this action from your local network."
119 )
120 elif action == CONF_ACTION_AUTH:
121 label_text = "Authenticated to Spotify. Don't forget to save to complete setup."
122 else:
123 label_text = "Authenticated to Spotify. No further action required."
124
125 # Build dev label text
126 if action == CONF_ACTION_AUTH_DEV:
127 dev_label_text = "Developer session authenticated. Don't forget to save to complete setup."
128 elif dev_authenticated:
129 dev_label_text = (
130 "Developer API session authenticated. "
131 "This session will be used for most API requests to avoid rate limits."
132 )
133 else:
134 dev_label_text = (
135 "Optionally, enter your own Spotify Developer Client ID to speed up performance."
136 )
137
138 return (
139 # Global authentication section
140 ConfigEntry(
141 key="label_text",
142 type=ConfigEntryType.LABEL,
143 label=label_text,
144 ),
145 ConfigEntry(
146 key=CONF_REFRESH_TOKEN_GLOBAL,
147 type=ConfigEntryType.SECURE_STRING,
148 label=CONF_REFRESH_TOKEN_GLOBAL,
149 hidden=True,
150 required=True,
151 default_value="",
152 value=values.get(CONF_REFRESH_TOKEN_GLOBAL, "") if values else "",
153 ),
154 ConfigEntry(
155 key=CONF_ACTION_AUTH,
156 type=ConfigEntryType.ACTION,
157 label="Authenticate with Spotify",
158 description="This button will redirect you to Spotify to authenticate.",
159 action=CONF_ACTION_AUTH,
160 # Show only when not authenticated
161 hidden=global_authenticated,
162 ),
163 ConfigEntry(
164 key=CONF_ACTION_CLEAR_AUTH,
165 type=ConfigEntryType.ACTION,
166 label="Clear authentication",
167 description="Clear the current authentication details.",
168 action=CONF_ACTION_CLEAR_AUTH,
169 action_label="Clear authentication",
170 required=False,
171 # Show only when authenticated
172 hidden=not global_authenticated,
173 ),
174 # Developer API section
175 ConfigEntry(
176 key="dev_label_text",
177 type=ConfigEntryType.LABEL,
178 label=dev_label_text,
179 category="Developer Token",
180 # Show only when global auth is complete
181 hidden=not global_authenticated,
182 ),
183 ConfigEntry(
184 key=CONF_CLIENT_ID,
185 type=ConfigEntryType.SECURE_STRING,
186 label="Client ID (optional)",
187 description="Enter your own Spotify Developer Client ID to speed up performance "
188 "by avoiding global rate limits. Some features like recommendations and similar "
189 "tracks will continue to use the global session due to Spotify API restrictions.\n\n"
190 f"Use {CALLBACK_REDIRECT_URL} as callback URL in your Spotify Developer app.",
191 required=False,
192 default_value="",
193 value=values.get(CONF_CLIENT_ID, "") if values else "",
194 category="Developer Token",
195 # Show only when global auth is complete
196 hidden=not global_authenticated or dev_authenticated,
197 ),
198 ConfigEntry(
199 key=CONF_REFRESH_TOKEN_DEV,
200 type=ConfigEntryType.SECURE_STRING,
201 label=CONF_REFRESH_TOKEN_DEV,
202 hidden=True,
203 required=False,
204 default_value="",
205 value=values.get(CONF_REFRESH_TOKEN_DEV, "") if values else "",
206 ),
207 ConfigEntry(
208 key=CONF_ACTION_AUTH_DEV,
209 type=ConfigEntryType.ACTION,
210 label="Authenticate Developer Session",
211 description="Authenticate with your custom Client ID.",
212 action=CONF_ACTION_AUTH_DEV,
213 category="Developer Token",
214 # Show only when global is authenticated and dev is NOT authenticated
215 # The client_id dependency is checked at action time, not visibility
216 hidden=not global_authenticated or dev_authenticated,
217 ),
218 ConfigEntry(
219 key=CONF_ACTION_CLEAR_AUTH_DEV,
220 type=ConfigEntryType.ACTION,
221 label="Clear Developer Authentication",
222 description="Clear the developer session authentication and client ID.",
223 action=CONF_ACTION_CLEAR_AUTH_DEV,
224 action_label="Clear developer authentication",
225 required=False,
226 category="Developer Token",
227 # Show when dev token is set
228 hidden=not global_authenticated or not dev_authenticated,
229 ),
230 # Sync options
231 ConfigEntry(
232 key=CONF_SYNC_PODCAST_PROGRESS,
233 type=ConfigEntryType.BOOLEAN,
234 label="Sync Podcast Progress from Spotify",
235 description="Automatically sync episode played status from Spotify to Music Assistant. "
236 "Episodes marked as played in Spotify will be marked as played in MA."
237 "Only enable this if you use both the Spotify app and Music Assistant "
238 "for podcast playback.",
239 default_value=False,
240 value=values.get(CONF_SYNC_PODCAST_PROGRESS, True) if values else True,
241 category="sync_options",
242 hidden=not global_authenticated,
243 ),
244 ConfigEntry(
245 key=CONF_SYNC_AUDIOBOOK_PROGRESS,
246 type=ConfigEntryType.BOOLEAN,
247 label="Sync Audiobook Progress from Spotify",
248 description="Automatically sync audiobook progress from Spotify to Music Assistant. "
249 "Progress from Spotify app will sync to MA when audiobooks are accessed. "
250 "Only enable this if you use both the Spotify app and Music Assistant "
251 "for audiobook playback.",
252 default_value=False,
253 value=values.get(CONF_SYNC_AUDIOBOOK_PROGRESS, False) if values else False,
254 category="sync_options",
255 hidden=not global_authenticated or not audiobooks_supported,
256 ),
257 )
258
259
260async def setup(
261 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
262) -> ProviderInstanceType:
263 """Initialize provider(instance) with given configuration."""
264 # Migration: handle legacy refresh_token
265 legacy_token = config.get_value(CONF_REFRESH_TOKEN_DEPRECATED)
266 global_token = config.get_value(CONF_REFRESH_TOKEN_GLOBAL)
267
268 if legacy_token and not global_token:
269 # Migrate legacy token to appropriate new key
270 if config.get_value(CONF_CLIENT_ID):
271 # Had custom client ID, migrate to dev token
272 mass.config.set_raw_provider_config_value(
273 config.instance_id, CONF_REFRESH_TOKEN_DEV, legacy_token, encrypted=True
274 )
275 else:
276 # No custom client ID, migrate to global token
277 mass.config.set_raw_provider_config_value(
278 config.instance_id, CONF_REFRESH_TOKEN_GLOBAL, legacy_token, encrypted=True
279 )
280 # Remove the deprecated legacy token from config
281 mass.config.set_raw_provider_config_value(
282 config.instance_id, CONF_REFRESH_TOKEN_DEPRECATED, None
283 )
284 # Re-fetch the updated config value
285 global_token = config.get_value(CONF_REFRESH_TOKEN_GLOBAL)
286
287 if global_token in (None, ""):
288 msg = "Re-Authentication required"
289 raise LoginFailed(msg)
290 return SpotifyProvider(mass, manifest, config, SUPPORTED_FEATURES)
291