music-assistant-server

11.6 KBPY
__init__.py
11.6 KB291 lines • python
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