Add Spotify media browser capability (#39240)
Co-authored-by: Tobias Sauerwein <cgtobi@gmail.com> Co-authored-by: Bram Kragten <mail@bramkragten.nl> Co-authored-by: Franck Nijhof <frenck@frenck.nl> Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
parent
27f3c0a302
commit
c8d49a8adf
7 changed files with 390 additions and 21 deletions
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
|
|
@ -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"]}
|
||||
)
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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." }
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue