mirror of
https://github.com/home-assistant/core.git
synced 2025-07-11 23:37:18 +00:00
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
@ -40,6 +40,11 @@ MEDIA_TYPE_IMAGE = "image"
|
|||||||
MEDIA_TYPE_URL = "url"
|
MEDIA_TYPE_URL = "url"
|
||||||
MEDIA_TYPE_GAME = "game"
|
MEDIA_TYPE_GAME = "game"
|
||||||
MEDIA_TYPE_APP = "app"
|
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_CLEAR_PLAYLIST = "clear_playlist"
|
||||||
SERVICE_PLAY_MEDIA = "play_media"
|
SERVICE_PLAY_MEDIA = "play_media"
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
"""The spotify integration."""
|
"""The spotify integration."""
|
||||||
|
import logging
|
||||||
|
|
||||||
from spotipy import Spotify, SpotifyException
|
from spotipy import Spotify, SpotifyException
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@ -16,7 +17,15 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
|||||||
)
|
)
|
||||||
from homeassistant.helpers.typing import ConfigType
|
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(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
@ -71,6 +80,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
DATA_SPOTIFY_SESSION: session,
|
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.async_create_task(
|
||||||
hass.config_entries.async_forward_entry_setup(entry, MEDIA_PLAYER_DOMAIN)
|
hass.config_entries.async_forward_entry_setup(entry, MEDIA_PLAYER_DOMAIN)
|
||||||
)
|
)
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
"""Config flow for Spotify."""
|
"""Config flow for Spotify."""
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from spotipy import Spotify
|
from spotipy import Spotify
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components import persistent_notification
|
||||||
from homeassistant.helpers import config_entry_oauth2_flow
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN, SPOTIFY_SCOPES
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -17,27 +20,25 @@ class SpotifyFlowHandler(
|
|||||||
"""Config flow to handle Spotify OAuth2 authentication."""
|
"""Config flow to handle Spotify OAuth2 authentication."""
|
||||||
|
|
||||||
DOMAIN = DOMAIN
|
DOMAIN = DOMAIN
|
||||||
|
VERSION = 1
|
||||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
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
|
@property
|
||||||
def logger(self) -> logging.Logger:
|
def logger(self) -> logging.Logger:
|
||||||
"""Return logger."""
|
"""Return logger."""
|
||||||
return logging.getLogger(__name__)
|
return logging.getLogger(__name__)
|
||||||
|
|
||||||
@property
|
@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."""
|
"""Extra data that needs to be appended to the authorize url."""
|
||||||
scopes = [
|
return {"scope": ",".join(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",
|
|
||||||
]
|
|
||||||
return {"scope": ",".join(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."""
|
"""Create an entry for Spotify."""
|
||||||
spotify = Spotify(auth=data["token"]["access_token"])
|
spotify = Spotify(auth=data["token"]["access_token"])
|
||||||
|
|
||||||
@ -48,6 +49,9 @@ class SpotifyFlowHandler(
|
|||||||
|
|
||||||
name = data["id"] = current_user["id"]
|
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"):
|
if current_user.get("display_name"):
|
||||||
name = current_user["display_name"]
|
name = current_user["display_name"]
|
||||||
data["name"] = name
|
data["name"] = name
|
||||||
@ -55,3 +59,37 @@ class SpotifyFlowHandler(
|
|||||||
await self.async_set_unique_id(current_user["id"])
|
await self.async_set_unique_id(current_user["id"])
|
||||||
|
|
||||||
return self.async_create_entry(title=name, data=data)
|
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_CLIENT = "spotify_client"
|
||||||
DATA_SPOTIFY_ME = "spotify_me"
|
DATA_SPOTIFY_ME = "spotify_me"
|
||||||
DATA_SPOTIFY_SESSION = "spotify_session"
|
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 import MediaPlayerEntity
|
||||||
from homeassistant.components.media_player.const import (
|
from homeassistant.components.media_player.const import (
|
||||||
|
MEDIA_TYPE_ALBUM,
|
||||||
|
MEDIA_TYPE_ARTIST,
|
||||||
MEDIA_TYPE_MUSIC,
|
MEDIA_TYPE_MUSIC,
|
||||||
MEDIA_TYPE_PLAYLIST,
|
MEDIA_TYPE_PLAYLIST,
|
||||||
|
MEDIA_TYPE_TRACK,
|
||||||
|
SUPPORT_BROWSE_MEDIA,
|
||||||
SUPPORT_NEXT_TRACK,
|
SUPPORT_NEXT_TRACK,
|
||||||
SUPPORT_PAUSE,
|
SUPPORT_PAUSE,
|
||||||
SUPPORT_PLAY,
|
SUPPORT_PLAY,
|
||||||
@ -23,6 +27,7 @@ from homeassistant.components.media_player.const import (
|
|||||||
SUPPORT_SHUFFLE_SET,
|
SUPPORT_SHUFFLE_SET,
|
||||||
SUPPORT_VOLUME_SET,
|
SUPPORT_VOLUME_SET,
|
||||||
)
|
)
|
||||||
|
from homeassistant.components.media_player.errors import BrowseError
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_ID,
|
CONF_ID,
|
||||||
@ -36,7 +41,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
|||||||
from homeassistant.helpers.entity import Entity
|
from homeassistant.helpers.entity import Entity
|
||||||
from homeassistant.util.dt import utc_from_timestamp
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -45,7 +56,8 @@ ICON = "mdi:spotify"
|
|||||||
SCAN_INTERVAL = timedelta(seconds=30)
|
SCAN_INTERVAL = timedelta(seconds=30)
|
||||||
|
|
||||||
SUPPORT_SPOTIFY = (
|
SUPPORT_SPOTIFY = (
|
||||||
SUPPORT_NEXT_TRACK
|
SUPPORT_BROWSE_MEDIA
|
||||||
|
| SUPPORT_NEXT_TRACK
|
||||||
| SUPPORT_PAUSE
|
| SUPPORT_PAUSE
|
||||||
| SUPPORT_PLAY
|
| SUPPORT_PLAY
|
||||||
| SUPPORT_PLAY_MEDIA
|
| SUPPORT_PLAY_MEDIA
|
||||||
@ -56,6 +68,23 @@ SUPPORT_SPOTIFY = (
|
|||||||
| SUPPORT_VOLUME_SET
|
| 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(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@ -108,6 +137,7 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
|
|||||||
self._name = f"Spotify {name}"
|
self._name = f"Spotify {name}"
|
||||||
self._session = session
|
self._session = session
|
||||||
self._spotify = spotify
|
self._spotify = spotify
|
||||||
|
self._scope_ok = set(session.token["scope"].split(" ")) == set(SPOTIFY_SCOPES)
|
||||||
|
|
||||||
self._currently_playing: Optional[dict] = {}
|
self._currently_playing: Optional[dict] = {}
|
||||||
self._devices: Optional[List[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.
|
# Yet, they do generate those types of URI in their official clients.
|
||||||
media_id = str(URL(media_id).with_query(None).with_fragment(None))
|
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]
|
kwargs["uris"] = [media_id]
|
||||||
elif media_type == MEDIA_TYPE_PLAYLIST:
|
elif media_type in PLAYABLE_MEDIA_TYPES:
|
||||||
kwargs["context_uri"] = media_id
|
kwargs["context_uri"] = media_id
|
||||||
else:
|
else:
|
||||||
_LOGGER.error("Media type %s is not supported", media_type)
|
_LOGGER.error("Media type %s is not supported", media_type)
|
||||||
@ -355,3 +385,145 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
devices = self._spotify.devices() or {}
|
devices = self._spotify.devices() or {}
|
||||||
self._devices = devices.get("devices", [])
|
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": {
|
"config": {
|
||||||
"step": {
|
"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": {
|
"abort": {
|
||||||
"already_setup": "You can only configure one Spotify account.",
|
"already_setup": "You can only configure one Spotify account.",
|
||||||
"authorize_url_timeout": "Timeout generating authorize url.",
|
"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." }
|
"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"
|
"?response_type=code&client_id=client"
|
||||||
"&redirect_uri=https://example.com/auth/external/callback"
|
"&redirect_uri=https://example.com/auth/external/callback"
|
||||||
f"&state={state}"
|
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)
|
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:
|
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"])
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
|
||||||
assert result["data"]["auth_implementation"] == DOMAIN
|
assert result["data"]["auth_implementation"] == DOMAIN
|
||||||
result["data"]["token"].pop("expires_at")
|
result["data"]["token"].pop("expires_at")
|
||||||
|
assert result["data"]["name"] == "frenck"
|
||||||
assert result["data"]["token"] == {
|
assert result["data"]["token"] == {
|
||||||
"refresh_token": "mock-refresh-token",
|
"refresh_token": "mock-refresh-token",
|
||||||
"access_token": "mock-access-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["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
assert result["reason"] == "connection_error"
|
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…
x
Reference in New Issue
Block a user