diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 0e7e038cdc1..651dcd94eff 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -40,6 +40,11 @@ MEDIA_TYPE_IMAGE = "image" MEDIA_TYPE_URL = "url" MEDIA_TYPE_GAME = "game" MEDIA_TYPE_APP = "app" +MEDIA_TYPE_ALBUM = "album" +MEDIA_TYPE_TRACK = "track" +MEDIA_TYPE_ARTIST = "artist" +MEDIA_TYPE_PODCAST = "podcast" +MEDIA_TYPE_SEASON = "season" SERVICE_CLEAR_PLAYLIST = "clear_playlist" SERVICE_PLAY_MEDIA = "play_media" diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index 619bcdb471f..d28875032da 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -1,4 +1,5 @@ """The spotify integration.""" +import logging from spotipy import Spotify, SpotifyException import voluptuous as vol @@ -16,7 +17,15 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from homeassistant.helpers.typing import ConfigType -from .const import DATA_SPOTIFY_CLIENT, DATA_SPOTIFY_ME, DATA_SPOTIFY_SESSION, DOMAIN +from .const import ( + DATA_SPOTIFY_CLIENT, + DATA_SPOTIFY_ME, + DATA_SPOTIFY_SESSION, + DOMAIN, + SPOTIFY_SCOPES, +) + +_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( { @@ -71,6 +80,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: DATA_SPOTIFY_SESSION: session, } + if set(session.token["scope"].split(" ")) <= set(SPOTIFY_SCOPES): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reauth"}, + data=entry.data, + ) + ) + hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, MEDIA_PLAYER_DOMAIN) ) diff --git a/homeassistant/components/spotify/config_flow.py b/homeassistant/components/spotify/config_flow.py index d619d3b2b10..14e45d58b39 100644 --- a/homeassistant/components/spotify/config_flow.py +++ b/homeassistant/components/spotify/config_flow.py @@ -1,12 +1,15 @@ """Config flow for Spotify.""" import logging +from typing import Any, Dict, Optional from spotipy import Spotify +import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import persistent_notification from homeassistant.helpers import config_entry_oauth2_flow -from .const import DOMAIN +from .const import DOMAIN, SPOTIFY_SCOPES _LOGGER = logging.getLogger(__name__) @@ -17,27 +20,25 @@ class SpotifyFlowHandler( """Config flow to handle Spotify OAuth2 authentication.""" DOMAIN = DOMAIN + VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + def __init__(self) -> None: + """Instantiate config flow.""" + super().__init__() + self.entry: Optional[Dict[str, Any]] = None + @property def logger(self) -> logging.Logger: """Return logger.""" return logging.getLogger(__name__) @property - def extra_authorize_data(self) -> dict: + def extra_authorize_data(self) -> Dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" - scopes = [ - # Needed to be able to control playback - "user-modify-playback-state", - # Needed in order to read available devices - "user-read-playback-state", - # Needed to determine if the user has Spotify Premium - "user-read-private", - ] - return {"scope": ",".join(scopes)} + return {"scope": ",".join(SPOTIFY_SCOPES)} - async def async_oauth_create_entry(self, data: dict) -> dict: + async def async_oauth_create_entry(self, data: Dict[str, Any]) -> Dict[str, Any]: """Create an entry for Spotify.""" spotify = Spotify(auth=data["token"]["access_token"]) @@ -48,6 +49,9 @@ class SpotifyFlowHandler( name = data["id"] = current_user["id"] + if self.entry and self.entry["id"] != current_user["id"]: + return self.async_abort(reason="reauth_account_mismatch") + if current_user.get("display_name"): name = current_user["display_name"] data["name"] = name @@ -55,3 +59,37 @@ class SpotifyFlowHandler( await self.async_set_unique_id(current_user["id"]) return self.async_create_entry(title=name, data=data) + + async def async_step_reauth(self, entry: Dict[str, Any]) -> Dict[str, Any]: + """Perform reauth upon migration of old entries.""" + if entry: + self.entry = entry + + assert self.hass + persistent_notification.async_create( + self.hass, + f"Spotify integration for account {entry['id']} needs to be re-authenticated. Please go to the integrations page to re-configure it.", + "Spotify re-authentication", + "spotify_reauth", + ) + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={"account": self.entry["id"]}, + data_schema=vol.Schema({}), + errors={}, + ) + + assert self.hass + persistent_notification.async_dismiss(self.hass, "spotify_reauth") + + return await self.async_step_pick_implementation( + user_input={"implementation": self.entry["auth_implementation"]} + ) diff --git a/homeassistant/components/spotify/const.py b/homeassistant/components/spotify/const.py index f508c9b2938..6b677aca996 100644 --- a/homeassistant/components/spotify/const.py +++ b/homeassistant/components/spotify/const.py @@ -5,3 +5,20 @@ DOMAIN = "spotify" DATA_SPOTIFY_CLIENT = "spotify_client" DATA_SPOTIFY_ME = "spotify_me" DATA_SPOTIFY_SESSION = "spotify_session" + +SPOTIFY_SCOPES = [ + # Needed to be able to control playback + "user-modify-playback-state", + # Needed in order to read available devices + "user-read-playback-state", + # Needed to determine if the user has Spotify Premium + "user-read-private", + # Needed for media browsing + "playlist-read-private", + "playlist-read-collaborative", + "user-library-read", + "user-top-read", + "user-read-playback-position", + "user-read-recently-played", + "user-follow-read", +] diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 0446500dba2..1b3140327ed 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -11,8 +11,12 @@ from yarl import URL from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( + MEDIA_TYPE_ALBUM, + MEDIA_TYPE_ARTIST, MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_TRACK, + SUPPORT_BROWSE_MEDIA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -23,6 +27,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_SHUFFLE_SET, SUPPORT_VOLUME_SET, ) +from homeassistant.components.media_player.errors import BrowseError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ID, @@ -36,7 +41,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.entity import Entity from homeassistant.util.dt import utc_from_timestamp -from .const import DATA_SPOTIFY_CLIENT, DATA_SPOTIFY_ME, DATA_SPOTIFY_SESSION, DOMAIN +from .const import ( + DATA_SPOTIFY_CLIENT, + DATA_SPOTIFY_ME, + DATA_SPOTIFY_SESSION, + DOMAIN, + SPOTIFY_SCOPES, +) _LOGGER = logging.getLogger(__name__) @@ -45,7 +56,8 @@ ICON = "mdi:spotify" SCAN_INTERVAL = timedelta(seconds=30) SUPPORT_SPOTIFY = ( - SUPPORT_NEXT_TRACK + SUPPORT_BROWSE_MEDIA + | SUPPORT_NEXT_TRACK | SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA @@ -56,6 +68,23 @@ SUPPORT_SPOTIFY = ( | SUPPORT_VOLUME_SET ) +BROWSE_LIMIT = 48 + +PLAYABLE_MEDIA_TYPES = [ + MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_ALBUM, + MEDIA_TYPE_ARTIST, + MEDIA_TYPE_TRACK, +] + +LIBRARY_MAP = { + "user_playlists": "Playlists", + "featured_playlists": "Featured Playlists", + "new_releases": "New Releases", + "current_user_top_artists": "Top Artists", + "current_user_recently_played": "Recently played", +} + async def async_setup_entry( hass: HomeAssistant, @@ -108,6 +137,7 @@ class SpotifyMediaPlayer(MediaPlayerEntity): self._name = f"Spotify {name}" self._session = session self._spotify = spotify + self._scope_ok = set(session.token["scope"].split(" ")) == set(SPOTIFY_SCOPES) self._currently_playing: Optional[dict] = {} self._devices: Optional[List[dict]] = [] @@ -308,9 +338,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity): # Yet, they do generate those types of URI in their official clients. media_id = str(URL(media_id).with_query(None).with_fragment(None)) - if media_type == MEDIA_TYPE_MUSIC: + if media_type in (MEDIA_TYPE_TRACK, MEDIA_TYPE_MUSIC): kwargs["uris"] = [media_id] - elif media_type == MEDIA_TYPE_PLAYLIST: + elif media_type in PLAYABLE_MEDIA_TYPES: kwargs["context_uri"] = media_id else: _LOGGER.error("Media type %s is not supported", media_type) @@ -355,3 +385,145 @@ class SpotifyMediaPlayer(MediaPlayerEntity): devices = self._spotify.devices() or {} self._devices = devices.get("devices", []) + + async def async_browse_media(self, media_content_type=None, media_content_id=None): + """Implement the websocket media browsing helper.""" + if not self._scope_ok: + raise NotImplementedError + + if media_content_type in [None, "library"]: + return await self.hass.async_add_executor_job(library_payload) + + payload = { + "media_content_type": media_content_type, + "media_content_id": media_content_id, + } + response = await self.hass.async_add_executor_job( + build_item_response, self._spotify, payload + ) + if response is None: + raise BrowseError( + f"Media not found: {media_content_type} / {media_content_id}" + ) + return response + + +def build_item_response(spotify, payload): + """Create response payload for the provided media query.""" + media_content_type = payload.get("media_content_type") + title = None + if media_content_type == "user_playlists": + media = spotify.current_user_playlists(limit=BROWSE_LIMIT) + items = media.get("items", []) + elif media_content_type == "current_user_recently_played": + media = spotify.current_user_recently_played(limit=BROWSE_LIMIT) + items = media.get("items", []) + elif media_content_type == "featured_playlists": + media = spotify.featured_playlists(limit=BROWSE_LIMIT) + items = media.get("playlists", {}).get("items", []) + elif media_content_type == "current_user_top_artists": + media = spotify.current_user_top_artists(limit=BROWSE_LIMIT) + items = media.get("items", []) + elif media_content_type == "new_releases": + media = spotify.new_releases(limit=BROWSE_LIMIT) + items = media.get("albums", {}).get("items", []) + elif media_content_type == MEDIA_TYPE_PLAYLIST: + media = spotify.playlist(payload["media_content_id"]) + items = media.get("tracks", {}).get("items", []) + elif media_content_type == MEDIA_TYPE_ALBUM: + media = spotify.album(payload["media_content_id"]) + items = media.get("tracks", {}).get("items", []) + elif media_content_type == MEDIA_TYPE_ARTIST: + media = spotify.artist_albums(payload["media_content_id"], limit=BROWSE_LIMIT) + title = spotify.artist(payload["media_content_id"]).get("name") + items = media.get("items", []) + else: + media = None + + if media is None: + return None + + response = { + "media_content_id": payload.get("media_content_id"), + "media_content_type": payload.get("media_content_type"), + "can_play": payload.get("media_content_type") in PLAYABLE_MEDIA_TYPES, + "children": [item_payload(item) for item in items], + } + + if "name" in media: + response["title"] = media.get("name") + elif title: + response["title"] = title + else: + response["title"] = LIBRARY_MAP.get(payload["media_content_id"]) + + if "images" in media: + response["thumbnail"] = fetch_image_url(media) + + return response + + +def item_payload(item): + """ + Create response payload for a single media item. + + Used by async_browse_media. + """ + if ( + MEDIA_TYPE_TRACK in item + or item.get("type") != MEDIA_TYPE_ALBUM + and "playlists" in item + ): + track = item.get(MEDIA_TYPE_TRACK) + payload = { + "title": track.get("name"), + "thumbnail": fetch_image_url(track.get(MEDIA_TYPE_ALBUM, {})), + "media_content_id": track.get("uri"), + "media_content_type": MEDIA_TYPE_TRACK, + "can_play": True, + } + else: + payload = { + "title": item.get("name"), + "thumbnail": fetch_image_url(item), + "media_content_id": item.get("uri"), + "media_content_type": item.get("type"), + "can_play": item.get("type") in PLAYABLE_MEDIA_TYPES, + } + + if item.get("type") not in [None, MEDIA_TYPE_TRACK]: + payload["can_expand"] = True + + return payload + + +def library_payload(): + """ + Create response payload to describe contents of a specific library. + + Used by async_browse_media. + """ + library_info = { + "title": "Media Library", + "media_content_id": "library", + "media_content_type": "library", + "can_play": False, + "can_expand": True, + "children": [], + } + + for item in [{"name": n, "type": t} for t, n in LIBRARY_MAP.items()]: + library_info["children"].append( + item_payload( + {"name": item["name"], "type": item["type"], "uri": item["type"]} + ) + ) + return library_info + + +def fetch_image_url(item): + """Fetch image url.""" + try: + return item.get("images", [])[0].get("url") + except IndexError: + return diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index c7831e31ca4..85ff9ff267b 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -1,12 +1,17 @@ { "config": { "step": { - "pick_implementation": { "title": "Pick Authentication Method" } + "pick_implementation": { "title": "Pick Authentication Method" }, + "reauth_confirm": { + "title": "Re-authenticate with Spotify", + "description": "The Spotify integration needs to re-authenticate with Spotify for account: {account}" + } }, "abort": { "already_setup": "You can only configure one Spotify account.", "authorize_url_timeout": "Timeout generating authorize url.", - "missing_configuration": "The Spotify integration is not configured. Please follow the documentation." + "missing_configuration": "The Spotify integration is not configured. Please follow the documentation.", + "reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication." }, "create_entry": { "default": "Successfully authenticated with Spotify." } } diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index cc10ddb887d..3b3c85dd828 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -64,7 +64,9 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock, current_request): "?response_type=code&client_id=client" "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}" - "&scope=user-modify-playback-state,user-read-playback-state,user-read-private" + "&scope=user-modify-playback-state,user-read-playback-state,user-read-private," + "playlist-read-private,playlist-read-collaborative,user-library-read," + "user-top-read,user-read-playback-position,user-read-recently-played,user-follow-read" ) client = await aiohttp_client(hass.http.app) @@ -83,11 +85,15 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock, current_request): ) with patch("homeassistant.components.spotify.config_flow.Spotify") as spotify_mock: - spotify_mock.return_value.current_user.return_value = {"id": "fake_id"} + spotify_mock.return_value.current_user.return_value = { + "id": "fake_id", + "display_name": "frenck", + } result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["data"]["auth_implementation"] == DOMAIN result["data"]["token"].pop("expires_at") + assert result["data"]["name"] == "frenck" assert result["data"]["token"] == { "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", @@ -136,3 +142,111 @@ async def test_abort_if_spotify_error( assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "connection_error" + + +async def test_reauthentication(hass, aiohttp_client, aioclient_mock, current_request): + """Test Spotify reauthentication.""" + await setup.async_setup_component( + hass, + DOMAIN, + { + DOMAIN: {CONF_CLIENT_ID: "client", CONF_CLIENT_SECRET: "secret"}, + "http": {"base_url": "https://example.com"}, + }, + ) + + old_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=123, + version=1, + data={"id": "frenck", "auth_implementation": DOMAIN}, + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=old_entry.data + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {}) + + # pylint: disable=protected-access + state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]}) + client = await aiohttp_client(hass.http.app) + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://accounts.spotify.com/api/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch("homeassistant.components.spotify.config_flow.Spotify") as spotify_mock: + spotify_mock.return_value.current_user.return_value = {"id": "frenck"} + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["data"]["auth_implementation"] == DOMAIN + result["data"]["token"].pop("expires_at") + assert result["data"]["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + + +async def test_reauth_account_mismatch( + hass, aiohttp_client, aioclient_mock, current_request +): + """Test Spotify reauthentication with different account.""" + await setup.async_setup_component( + hass, + DOMAIN, + { + DOMAIN: {CONF_CLIENT_ID: "client", CONF_CLIENT_SECRET: "secret"}, + "http": {"base_url": "https://example.com"}, + }, + ) + + old_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=123, + version=1, + data={"id": "frenck", "auth_implementation": DOMAIN}, + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=old_entry.data + ) + + flows = hass.config_entries.flow.async_progress() + result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {}) + + # pylint: disable=protected-access + state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]}) + client = await aiohttp_client(hass.http.app) + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://accounts.spotify.com/api/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch("homeassistant.components.spotify.config_flow.Spotify") as spotify_mock: + spotify_mock.return_value.current_user.return_value = {"id": "fake_id"} + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_account_mismatch"