Rewrite of Spotify integration (#30717)
* Rewrite of Spotify integration * Update homeassistant/components/spotify/config_flow.py Co-Authored-By: Paulus Schoutsen <balloob@gmail.com> * Remove configurator dependency * Strip whitespace from device model in case Spotify product is missing * Ensure domain dict exists in hass data on setup entry * Simply config validation for client id and secret * Abort flow on any exception from spotipy * Add tests for config flow * Gen requirements all * Add test package __init__ * Remove Spotify from coveragerc * Made alias handling more robuust * Fix supported_features for Spotify free and open accounts * Improve error message in the logs * Re-implement Spotify media_player * Change media content type when play a playlist * Process review suggestions * Move Spotify init, static current user and supported_features * Remove unneeded me call * Remove playlist content type due to frontend issues * Improve playlist handling, when context is missing * Handle entity disabled correctly * Handle being offline/unavailable correctly * Bump Spotipy to 2.7.1 * Update coverage RC, mark integration silver * Remove URI limitation, lib supports all Spotify URI's now * Final cleanup * Addresses Pylint error Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
parent
4571cf01e2
commit
7e4b9adc3d
15 changed files with 625 additions and 324 deletions
|
@ -662,6 +662,7 @@ omit =
|
||||||
homeassistant/components/speedtestdotnet/*
|
homeassistant/components/speedtestdotnet/*
|
||||||
homeassistant/components/spider/*
|
homeassistant/components/spider/*
|
||||||
homeassistant/components/spotcrime/sensor.py
|
homeassistant/components/spotcrime/sensor.py
|
||||||
|
homeassistant/components/spotify/__init__.py
|
||||||
homeassistant/components/spotify/media_player.py
|
homeassistant/components/spotify/media_player.py
|
||||||
homeassistant/components/squeezebox/*
|
homeassistant/components/squeezebox/*
|
||||||
homeassistant/components/starline/*
|
homeassistant/components/starline/*
|
||||||
|
|
|
@ -315,6 +315,7 @@ homeassistant/components/songpal/* @rytilahti
|
||||||
homeassistant/components/spaceapi/* @fabaff
|
homeassistant/components/spaceapi/* @fabaff
|
||||||
homeassistant/components/speedtestdotnet/* @rohankapoorcom
|
homeassistant/components/speedtestdotnet/* @rohankapoorcom
|
||||||
homeassistant/components/spider/* @peternijssen
|
homeassistant/components/spider/* @peternijssen
|
||||||
|
homeassistant/components/spotify/* @frenck
|
||||||
homeassistant/components/sql/* @dgomes
|
homeassistant/components/sql/* @dgomes
|
||||||
homeassistant/components/starline/* @anonym-tsk
|
homeassistant/components/starline/* @anonym-tsk
|
||||||
homeassistant/components/statistics/* @fabaff
|
homeassistant/components/statistics/* @fabaff
|
||||||
|
|
18
homeassistant/components/spotify/.translations/en.json
Normal file
18
homeassistant/components/spotify/.translations/en.json
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"pick_implementation": {
|
||||||
|
"title": "Pick Authentication Method"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"create_entry": {
|
||||||
|
"default": "Successfully authenticated with Spotify."
|
||||||
|
},
|
||||||
|
"title": "Spotify"
|
||||||
|
}
|
||||||
|
}
|
|
@ -1 +1,97 @@
|
||||||
"""The spotify component."""
|
"""The spotify integration."""
|
||||||
|
|
||||||
|
from spotipy import Spotify, SpotifyException
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||||
|
from homeassistant.components.spotify import config_flow
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import ATTR_CREDENTIALS
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
|
||||||
|
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||||
|
OAuth2Session,
|
||||||
|
async_get_config_entry_implementation,
|
||||||
|
)
|
||||||
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_CLIENT_ID,
|
||||||
|
CONF_CLIENT_SECRET,
|
||||||
|
DATA_SPOTIFY_CLIENT,
|
||||||
|
DATA_SPOTIFY_ME,
|
||||||
|
DATA_SPOTIFY_SESSION,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
DOMAIN: vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Inclusive(CONF_CLIENT_ID, ATTR_CREDENTIALS): cv.string,
|
||||||
|
vol.Inclusive(CONF_CLIENT_SECRET, ATTR_CREDENTIALS): cv.string,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
extra=vol.ALLOW_EXTRA,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
|
"""Set up the Spotify integration."""
|
||||||
|
if DOMAIN not in config:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if CONF_CLIENT_ID in config[DOMAIN]:
|
||||||
|
config_flow.SpotifyFlowHandler.async_register_implementation(
|
||||||
|
hass,
|
||||||
|
config_entry_oauth2_flow.LocalOAuth2Implementation(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
config[DOMAIN][CONF_CLIENT_ID],
|
||||||
|
config[DOMAIN][CONF_CLIENT_SECRET],
|
||||||
|
"https://accounts.spotify.com/authorize",
|
||||||
|
"https://accounts.spotify.com/api/token",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up Spotify from a config entry."""
|
||||||
|
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||||
|
session = OAuth2Session(hass, entry, implementation)
|
||||||
|
await session.async_ensure_token_valid()
|
||||||
|
spotify = Spotify(auth=session.token["access_token"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_user = await hass.async_add_executor_job(spotify.me)
|
||||||
|
except SpotifyException:
|
||||||
|
raise ConfigEntryNotReady
|
||||||
|
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = {
|
||||||
|
DATA_SPOTIFY_CLIENT: spotify,
|
||||||
|
DATA_SPOTIFY_ME: current_user,
|
||||||
|
DATA_SPOTIFY_SESSION: session,
|
||||||
|
}
|
||||||
|
|
||||||
|
hass.async_create_task(
|
||||||
|
hass.config_entries.async_forward_entry_setup(entry, MEDIA_PLAYER_DOMAIN)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload Spotify config entry."""
|
||||||
|
# Unload entities for this entry/device.
|
||||||
|
await hass.config_entries.async_forward_entry_unload(entry, MEDIA_PLAYER_DOMAIN)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
del hass.data[DOMAIN][entry.entry_id]
|
||||||
|
if not hass.data[DOMAIN]:
|
||||||
|
del hass.data[DOMAIN]
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
57
homeassistant/components/spotify/config_flow.py
Normal file
57
homeassistant/components/spotify/config_flow.py
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
"""Config flow for Spotify."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from spotipy import Spotify
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SpotifyFlowHandler(
|
||||||
|
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||||
|
):
|
||||||
|
"""Config flow to handle Spotify OAuth2 authentication."""
|
||||||
|
|
||||||
|
DOMAIN = DOMAIN
|
||||||
|
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logger(self) -> logging.Logger:
|
||||||
|
"""Return logger."""
|
||||||
|
return logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_authorize_data(self) -> dict:
|
||||||
|
"""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)}
|
||||||
|
|
||||||
|
async def async_oauth_create_entry(self, data: dict) -> dict:
|
||||||
|
"""Create an entry for Spotify."""
|
||||||
|
spotify = Spotify(auth=data["token"]["access_token"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_user = await self.hass.async_add_executor_job(spotify.current_user)
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
return self.async_abort(reason="connection_error")
|
||||||
|
|
||||||
|
name = data["id"] = current_user["id"]
|
||||||
|
|
||||||
|
if current_user.get("display_name"):
|
||||||
|
name = current_user["display_name"]
|
||||||
|
data["name"] = name
|
||||||
|
|
||||||
|
await self.async_set_unique_id(current_user["id"])
|
||||||
|
|
||||||
|
return self.async_create_entry(title=name, data=data)
|
10
homeassistant/components/spotify/const.py
Normal file
10
homeassistant/components/spotify/const.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
"""Define constants for the Spotify integration."""
|
||||||
|
|
||||||
|
DOMAIN = "spotify"
|
||||||
|
|
||||||
|
CONF_CLIENT_ID = "client_id"
|
||||||
|
CONF_CLIENT_SECRET = "client_secret"
|
||||||
|
|
||||||
|
DATA_SPOTIFY_CLIENT = "spotify_client"
|
||||||
|
DATA_SPOTIFY_ME = "spotify_me"
|
||||||
|
DATA_SPOTIFY_SESSION = "spotify_session"
|
|
@ -2,7 +2,10 @@
|
||||||
"domain": "spotify",
|
"domain": "spotify",
|
||||||
"name": "Spotify",
|
"name": "Spotify",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/spotify",
|
"documentation": "https://www.home-assistant.io/integrations/spotify",
|
||||||
"requirements": ["spotipy-homeassistant==2.4.4.dev1"],
|
"requirements": ["spotipy==2.7.1"],
|
||||||
"dependencies": ["configurator", "http"],
|
"zeroconf": ["_spotify-connect._tcp.local."],
|
||||||
"codeowners": []
|
"dependencies": ["http"],
|
||||||
|
"codeowners": ["@frenck"],
|
||||||
|
"config_flow": true,
|
||||||
|
"quality_scale": "silver"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,15 @@
|
||||||
"""Support for interacting with Spotify Connect."""
|
"""Support for interacting with Spotify Connect."""
|
||||||
|
from asyncio import run_coroutine_threadsafe
|
||||||
|
import datetime as dt
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
import random
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
import spotipy
|
from aiohttp import ClientError
|
||||||
import spotipy.oauth2
|
from spotipy import Spotify, SpotifyException
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.components.http import HomeAssistantView
|
from homeassistant.components.media_player import MediaPlayerDevice
|
||||||
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice
|
|
||||||
from homeassistant.components.media_player.const import (
|
from homeassistant.components.media_player.const import (
|
||||||
ATTR_MEDIA_CONTENT_ID,
|
|
||||||
MEDIA_TYPE_MUSIC,
|
MEDIA_TYPE_MUSIC,
|
||||||
MEDIA_TYPE_PLAYLIST,
|
MEDIA_TYPE_PLAYLIST,
|
||||||
SUPPORT_NEXT_TRACK,
|
SUPPORT_NEXT_TRACK,
|
||||||
|
@ -18,374 +17,325 @@ from homeassistant.components.media_player.const import (
|
||||||
SUPPORT_PLAY,
|
SUPPORT_PLAY,
|
||||||
SUPPORT_PLAY_MEDIA,
|
SUPPORT_PLAY_MEDIA,
|
||||||
SUPPORT_PREVIOUS_TRACK,
|
SUPPORT_PREVIOUS_TRACK,
|
||||||
|
SUPPORT_SEEK,
|
||||||
SUPPORT_SELECT_SOURCE,
|
SUPPORT_SELECT_SOURCE,
|
||||||
SUPPORT_SHUFFLE_SET,
|
SUPPORT_SHUFFLE_SET,
|
||||||
SUPPORT_VOLUME_SET,
|
SUPPORT_VOLUME_SET,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_NAME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import callback
|
from homeassistant.const import (
|
||||||
import homeassistant.helpers.config_validation as cv
|
CONF_ID,
|
||||||
|
CONF_NAME,
|
||||||
|
STATE_IDLE,
|
||||||
|
STATE_PAUSED,
|
||||||
|
STATE_PLAYING,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
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
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
AUTH_CALLBACK_NAME = "api:spotify"
|
|
||||||
AUTH_CALLBACK_PATH = "/api/spotify"
|
|
||||||
|
|
||||||
CONF_ALIASES = "aliases"
|
|
||||||
CONF_CACHE_PATH = "cache_path"
|
|
||||||
CONF_CLIENT_ID = "client_id"
|
|
||||||
CONF_CLIENT_SECRET = "client_secret"
|
|
||||||
|
|
||||||
CONFIGURATOR_DESCRIPTION = (
|
|
||||||
"To link your Spotify account, click the link, login, and authorize:"
|
|
||||||
)
|
|
||||||
CONFIGURATOR_LINK_NAME = "Link Spotify account"
|
|
||||||
CONFIGURATOR_SUBMIT_CAPTION = "I authorized successfully"
|
|
||||||
|
|
||||||
DEFAULT_CACHE_PATH = ".spotify-token-cache"
|
|
||||||
DEFAULT_NAME = "Spotify"
|
|
||||||
DOMAIN = "spotify"
|
|
||||||
|
|
||||||
SERVICE_PLAY_PLAYLIST = "play_playlist"
|
|
||||||
ATTR_RANDOM_SONG = "random_song"
|
|
||||||
|
|
||||||
PLAY_PLAYLIST_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string,
|
|
||||||
vol.Optional(ATTR_RANDOM_SONG, default=False): cv.boolean,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ICON = "mdi:spotify"
|
ICON = "mdi:spotify"
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=30)
|
SCAN_INTERVAL = timedelta(seconds=30)
|
||||||
|
|
||||||
SCOPE = "user-read-playback-state user-modify-playback-state user-read-private"
|
|
||||||
|
|
||||||
SUPPORT_SPOTIFY = (
|
SUPPORT_SPOTIFY = (
|
||||||
SUPPORT_VOLUME_SET
|
SUPPORT_NEXT_TRACK
|
||||||
| SUPPORT_PAUSE
|
| SUPPORT_PAUSE
|
||||||
| SUPPORT_PLAY
|
| SUPPORT_PLAY
|
||||||
| SUPPORT_NEXT_TRACK
|
|
||||||
| SUPPORT_PREVIOUS_TRACK
|
|
||||||
| SUPPORT_SELECT_SOURCE
|
|
||||||
| SUPPORT_PLAY_MEDIA
|
| SUPPORT_PLAY_MEDIA
|
||||||
|
| SUPPORT_PREVIOUS_TRACK
|
||||||
|
| SUPPORT_SEEK
|
||||||
|
| SUPPORT_SELECT_SOURCE
|
||||||
| SUPPORT_SHUFFLE_SET
|
| SUPPORT_SHUFFLE_SET
|
||||||
)
|
| SUPPORT_VOLUME_SET
|
||||||
|
|
||||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_CLIENT_ID): cv.string,
|
|
||||||
vol.Required(CONF_CLIENT_SECRET): cv.string,
|
|
||||||
vol.Optional(CONF_NAME): cv.string,
|
|
||||||
vol.Optional(CONF_CACHE_PATH): cv.string,
|
|
||||||
vol.Optional(CONF_ALIASES, default={}): {cv.string: cv.string},
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def request_configuration(hass, config, add_entities, oauth):
|
async def async_setup_entry(
|
||||||
"""Request Spotify authorization."""
|
hass: HomeAssistant,
|
||||||
configurator = hass.components.configurator
|
entry: ConfigEntry,
|
||||||
hass.data[DOMAIN] = configurator.request_config(
|
async_add_entities: Callable[[List[Entity], bool], None],
|
||||||
DEFAULT_NAME,
|
) -> None:
|
||||||
lambda _: None,
|
"""Set up Spotify based on a config entry."""
|
||||||
link_name=CONFIGURATOR_LINK_NAME,
|
spotify = SpotifyMediaPlayer(
|
||||||
link_url=oauth.get_authorize_url(),
|
hass.data[DOMAIN][entry.entry_id][DATA_SPOTIFY_SESSION],
|
||||||
description=CONFIGURATOR_DESCRIPTION,
|
hass.data[DOMAIN][entry.entry_id][DATA_SPOTIFY_CLIENT],
|
||||||
submit_caption=CONFIGURATOR_SUBMIT_CAPTION,
|
hass.data[DOMAIN][entry.entry_id][DATA_SPOTIFY_ME],
|
||||||
|
entry.data[CONF_ID],
|
||||||
|
entry.data[CONF_NAME],
|
||||||
)
|
)
|
||||||
|
async_add_entities([spotify], True)
|
||||||
|
|
||||||
|
|
||||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
def spotify_exception_handler(func):
|
||||||
"""Set up the Spotify platform."""
|
"""Decorate Spotify calls to handle Spotify exception.
|
||||||
|
|
||||||
callback_url = f"{hass.config.api.base_url}{AUTH_CALLBACK_PATH}"
|
A decorator that wraps the passed in function, catches Spotify errors,
|
||||||
cache = config.get(CONF_CACHE_PATH, hass.config.path(DEFAULT_CACHE_PATH))
|
aiohttp exceptions and handles the availability of the media player.
|
||||||
oauth = spotipy.oauth2.SpotifyOAuth(
|
"""
|
||||||
config.get(CONF_CLIENT_ID),
|
|
||||||
config.get(CONF_CLIENT_SECRET),
|
|
||||||
callback_url,
|
|
||||||
scope=SCOPE,
|
|
||||||
cache_path=cache,
|
|
||||||
)
|
|
||||||
token_info = oauth.get_cached_token()
|
|
||||||
if not token_info:
|
|
||||||
_LOGGER.info("no token; requesting authorization")
|
|
||||||
hass.http.register_view(SpotifyAuthCallbackView(config, add_entities, oauth))
|
|
||||||
request_configuration(hass, config, add_entities, oauth)
|
|
||||||
return
|
|
||||||
if hass.data.get(DOMAIN):
|
|
||||||
configurator = hass.components.configurator
|
|
||||||
configurator.request_done(hass.data.get(DOMAIN))
|
|
||||||
del hass.data[DOMAIN]
|
|
||||||
player = SpotifyMediaPlayer(
|
|
||||||
oauth, config.get(CONF_NAME, DEFAULT_NAME), config[CONF_ALIASES]
|
|
||||||
)
|
|
||||||
add_entities([player], True)
|
|
||||||
|
|
||||||
def play_playlist_service(service):
|
def wrapper(self, *args, **kwargs):
|
||||||
media_content_id = service.data[ATTR_MEDIA_CONTENT_ID]
|
try:
|
||||||
random_song = service.data.get(ATTR_RANDOM_SONG)
|
result = func(self, *args, **kwargs)
|
||||||
player.play_playlist(media_content_id, random_song)
|
self.player_available = True
|
||||||
|
return result
|
||||||
|
except (SpotifyException, ClientError):
|
||||||
|
self.player_available = False
|
||||||
|
|
||||||
hass.services.register(
|
return wrapper
|
||||||
DOMAIN,
|
|
||||||
SERVICE_PLAY_PLAYLIST,
|
|
||||||
play_playlist_service,
|
|
||||||
schema=PLAY_PLAYLIST_SCHEMA,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SpotifyAuthCallbackView(HomeAssistantView):
|
|
||||||
"""Spotify Authorization Callback View."""
|
|
||||||
|
|
||||||
requires_auth = False
|
|
||||||
url = AUTH_CALLBACK_PATH
|
|
||||||
name = AUTH_CALLBACK_NAME
|
|
||||||
|
|
||||||
def __init__(self, config, add_entities, oauth):
|
|
||||||
"""Initialize."""
|
|
||||||
self.config = config
|
|
||||||
self.add_entities = add_entities
|
|
||||||
self.oauth = oauth
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def get(self, request):
|
|
||||||
"""Receive authorization token."""
|
|
||||||
hass = request.app["hass"]
|
|
||||||
self.oauth.get_access_token(request.query["code"])
|
|
||||||
hass.async_add_job(setup_platform, hass, self.config, self.add_entities)
|
|
||||||
|
|
||||||
|
|
||||||
class SpotifyMediaPlayer(MediaPlayerDevice):
|
class SpotifyMediaPlayer(MediaPlayerDevice):
|
||||||
"""Representation of a Spotify controller."""
|
"""Representation of a Spotify controller."""
|
||||||
|
|
||||||
def __init__(self, oauth, name, aliases):
|
def __init__(self, session, spotify: Spotify, me: dict, user_id: str, name: str):
|
||||||
"""Initialize."""
|
"""Initialize."""
|
||||||
self._name = name
|
self._id = user_id
|
||||||
self._oauth = oauth
|
self._me = me
|
||||||
self._album = None
|
self._name = f"Spotify {name}"
|
||||||
self._title = None
|
self._session = session
|
||||||
self._artist = None
|
self._spotify = spotify
|
||||||
self._uri = None
|
|
||||||
self._image_url = None
|
|
||||||
self._state = None
|
|
||||||
self._current_device = None
|
|
||||||
self._devices = {}
|
|
||||||
self._volume = None
|
|
||||||
self._shuffle = False
|
|
||||||
self._player = None
|
|
||||||
self._user = None
|
|
||||||
self._aliases = aliases
|
|
||||||
self._token_info = self._oauth.get_cached_token()
|
|
||||||
|
|
||||||
def refresh_spotify_instance(self):
|
self._currently_playing: Optional[dict] = {}
|
||||||
"""Fetch a new spotify instance."""
|
self._devices: Optional[List[dict]] = []
|
||||||
|
self._playlist: Optional[dict] = None
|
||||||
|
self._spotify: Spotify = None
|
||||||
|
|
||||||
token_refreshed = False
|
self.player_available = False
|
||||||
need_token = self._token_info is None or self._oauth.is_token_expired(
|
|
||||||
self._token_info
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return the name."""
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def icon(self) -> str:
|
||||||
|
"""Return the icon."""
|
||||||
|
return ICON
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return True if entity is available."""
|
||||||
|
return self.player_available
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self) -> str:
|
||||||
|
"""Return the unique ID."""
|
||||||
|
return self._id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> Dict[str, Any]:
|
||||||
|
"""Return device information about this entity."""
|
||||||
|
if self._me is not None:
|
||||||
|
model = self._me["product"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"identifiers": {(DOMAIN, self._id)},
|
||||||
|
"manufacturer": "Spotify AB",
|
||||||
|
"model": f"Spotify {model}".rstrip(),
|
||||||
|
"name": self._name,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> Optional[str]:
|
||||||
|
"""Return the playback state."""
|
||||||
|
if not self._currently_playing:
|
||||||
|
return STATE_IDLE
|
||||||
|
if self._currently_playing["is_playing"]:
|
||||||
|
return STATE_PLAYING
|
||||||
|
return STATE_PAUSED
|
||||||
|
|
||||||
|
@property
|
||||||
|
def volume_level(self) -> Optional[float]:
|
||||||
|
"""Return the device volume."""
|
||||||
|
return self._currently_playing.get("device", {}).get("volume_percent", 0) / 100
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_content_id(self) -> Optional[str]:
|
||||||
|
"""Return the media URL."""
|
||||||
|
return self._currently_playing.get("item", {}).get("name")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_content_type(self) -> Optional[str]:
|
||||||
|
"""Return the media type."""
|
||||||
|
return MEDIA_TYPE_MUSIC
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_duration(self) -> Optional[int]:
|
||||||
|
"""Duration of current playing media in seconds."""
|
||||||
|
if self._currently_playing.get("item") is None:
|
||||||
|
return None
|
||||||
|
return self._currently_playing["item"]["duration_ms"] / 1000
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_position(self) -> Optional[str]:
|
||||||
|
"""Position of current playing media in seconds."""
|
||||||
|
if not self._currently_playing:
|
||||||
|
return None
|
||||||
|
return self._currently_playing["progress_ms"] / 1000
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_position_updated_at(self) -> Optional[dt.datetime]:
|
||||||
|
"""When was the position of the current playing media valid."""
|
||||||
|
if not self._currently_playing:
|
||||||
|
return None
|
||||||
|
return utc_from_timestamp(self._currently_playing["timestamp"] / 1000)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_image_url(self) -> Optional[str]:
|
||||||
|
"""Return the media image URL."""
|
||||||
|
if (
|
||||||
|
self._currently_playing.get("item") is None
|
||||||
|
or not self._currently_playing["item"]["album"]["images"]
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return self._currently_playing["item"]["album"]["images"][0]["url"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_image_remotely_accessible(self) -> bool:
|
||||||
|
"""If the image url is remotely accessible."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_title(self) -> Optional[str]:
|
||||||
|
"""Return the media title."""
|
||||||
|
return self._currently_playing.get("item", {}).get("name")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_artist(self) -> Optional[str]:
|
||||||
|
"""Return the media artist."""
|
||||||
|
if self._currently_playing.get("item") is None:
|
||||||
|
return None
|
||||||
|
return ", ".join(
|
||||||
|
[artist["name"] for artist in self._currently_playing["item"]["artists"]]
|
||||||
)
|
)
|
||||||
if need_token:
|
|
||||||
new_token = self._oauth.refresh_access_token(
|
|
||||||
self._token_info["refresh_token"]
|
|
||||||
)
|
|
||||||
# skip when refresh failed
|
|
||||||
if new_token is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._token_info = new_token
|
@property
|
||||||
token_refreshed = True
|
def media_album_name(self) -> Optional[str]:
|
||||||
if self._player is None or token_refreshed:
|
"""Return the media album."""
|
||||||
self._player = spotipy.Spotify(auth=self._token_info.get("access_token"))
|
if self._currently_playing.get("item") is None:
|
||||||
self._user = self._player.me()
|
return None
|
||||||
|
return self._currently_playing["item"]["album"]["name"]
|
||||||
|
|
||||||
def update(self):
|
@property
|
||||||
"""Update state and attributes."""
|
def media_track(self) -> Optional[int]:
|
||||||
self.refresh_spotify_instance()
|
"""Track number of current playing media, music track only."""
|
||||||
|
return self._currently_playing.get("item", {}).get("track_number")
|
||||||
|
|
||||||
# Don't true update when token is expired
|
@property
|
||||||
if self._oauth.is_token_expired(self._token_info):
|
def media_playlist(self):
|
||||||
_LOGGER.warning("Spotify failed to update, token expired.")
|
"""Title of Playlist currently playing."""
|
||||||
return
|
if self._playlist is None:
|
||||||
|
return None
|
||||||
|
return self._playlist["name"]
|
||||||
|
|
||||||
# Available devices
|
@property
|
||||||
player_devices = self._player.devices()
|
def source(self) -> Optional[str]:
|
||||||
if player_devices is not None:
|
"""Return the current playback device."""
|
||||||
devices = player_devices.get("devices")
|
return self._currently_playing.get("device", {}).get("name")
|
||||||
if devices is not None:
|
|
||||||
old_devices = self._devices
|
|
||||||
self._devices = {
|
|
||||||
self._aliases.get(device.get("id"), device.get("name")): device.get(
|
|
||||||
"id"
|
|
||||||
)
|
|
||||||
for device in devices
|
|
||||||
}
|
|
||||||
device_diff = {
|
|
||||||
name: id
|
|
||||||
for name, id in self._devices.items()
|
|
||||||
if old_devices.get(name, None) is None
|
|
||||||
}
|
|
||||||
if device_diff:
|
|
||||||
_LOGGER.info("New Devices: %s", str(device_diff))
|
|
||||||
# Current playback state
|
|
||||||
current = self._player.current_playback()
|
|
||||||
if current is None:
|
|
||||||
self._state = STATE_IDLE
|
|
||||||
return
|
|
||||||
# Track metadata
|
|
||||||
item = current.get("item")
|
|
||||||
if item:
|
|
||||||
self._album = item.get("album").get("name")
|
|
||||||
self._title = item.get("name")
|
|
||||||
self._artist = ", ".join(
|
|
||||||
[artist.get("name") for artist in item.get("artists")]
|
|
||||||
)
|
|
||||||
self._uri = item.get("uri")
|
|
||||||
images = item.get("album").get("images")
|
|
||||||
self._image_url = images[0].get("url") if images else None
|
|
||||||
# Playing state
|
|
||||||
self._state = STATE_PAUSED
|
|
||||||
if current.get("is_playing"):
|
|
||||||
self._state = STATE_PLAYING
|
|
||||||
self._shuffle = current.get("shuffle_state")
|
|
||||||
device = current.get("device")
|
|
||||||
if device is None:
|
|
||||||
self._state = STATE_IDLE
|
|
||||||
else:
|
|
||||||
if device.get("volume_percent"):
|
|
||||||
self._volume = device.get("volume_percent") / 100
|
|
||||||
if device.get("name"):
|
|
||||||
self._current_device = device.get("name")
|
|
||||||
|
|
||||||
def set_volume_level(self, volume):
|
@property
|
||||||
|
def source_list(self) -> Optional[List[str]]:
|
||||||
|
"""Return a list of source devices."""
|
||||||
|
if not self._devices:
|
||||||
|
return None
|
||||||
|
return [device["name"] for device in self._devices]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shuffle(self) -> bool:
|
||||||
|
"""Shuffling state."""
|
||||||
|
return bool(self._currently_playing.get("shuffle_state"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_features(self) -> int:
|
||||||
|
"""Return the media player features that are supported."""
|
||||||
|
if self._me["product"] != "premium":
|
||||||
|
return 0
|
||||||
|
return SUPPORT_SPOTIFY
|
||||||
|
|
||||||
|
@spotify_exception_handler
|
||||||
|
def set_volume_level(self, volume: int) -> None:
|
||||||
"""Set the volume level."""
|
"""Set the volume level."""
|
||||||
self._player.volume(int(volume * 100))
|
self._spotify.volume(int(volume * 100))
|
||||||
|
|
||||||
def set_shuffle(self, shuffle):
|
@spotify_exception_handler
|
||||||
"""Enable/Disable shuffle mode."""
|
def media_play(self) -> None:
|
||||||
self._player.shuffle(shuffle)
|
|
||||||
|
|
||||||
def media_next_track(self):
|
|
||||||
"""Skip to next track."""
|
|
||||||
self._player.next_track()
|
|
||||||
|
|
||||||
def media_previous_track(self):
|
|
||||||
"""Skip to previous track."""
|
|
||||||
self._player.previous_track()
|
|
||||||
|
|
||||||
def media_play(self):
|
|
||||||
"""Start or resume playback."""
|
"""Start or resume playback."""
|
||||||
self._player.start_playback()
|
self._spotify.start_playback()
|
||||||
|
|
||||||
def media_pause(self):
|
@spotify_exception_handler
|
||||||
|
def media_pause(self) -> None:
|
||||||
"""Pause playback."""
|
"""Pause playback."""
|
||||||
self._player.pause_playback()
|
self._spotify.pause_playback()
|
||||||
|
|
||||||
def select_source(self, source):
|
@spotify_exception_handler
|
||||||
"""Select playback device."""
|
def media_previous_track(self) -> None:
|
||||||
if self._devices:
|
"""Skip to previous track."""
|
||||||
self._player.transfer_playback(
|
self._spotify.previous_track()
|
||||||
self._devices[source], self._state == STATE_PLAYING
|
|
||||||
)
|
|
||||||
|
|
||||||
def play_media(self, media_type, media_id, **kwargs):
|
@spotify_exception_handler
|
||||||
|
def media_next_track(self) -> None:
|
||||||
|
"""Skip to next track."""
|
||||||
|
self._spotify.next_track()
|
||||||
|
|
||||||
|
@spotify_exception_handler
|
||||||
|
def media_seek(self, position):
|
||||||
|
"""Send seek command."""
|
||||||
|
self._spotify.seek_track(int(position * 1000))
|
||||||
|
|
||||||
|
@spotify_exception_handler
|
||||||
|
def play_media(self, media_type: str, media_id: str, **kwargs) -> None:
|
||||||
"""Play media."""
|
"""Play media."""
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
|
|
||||||
if media_type == MEDIA_TYPE_MUSIC:
|
if media_type == MEDIA_TYPE_MUSIC:
|
||||||
kwargs["uris"] = [media_id]
|
kwargs["uris"] = [media_id]
|
||||||
elif media_type == MEDIA_TYPE_PLAYLIST:
|
elif media_type == MEDIA_TYPE_PLAYLIST:
|
||||||
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)
|
||||||
return
|
return
|
||||||
if not media_id.startswith("spotify:"):
|
|
||||||
_LOGGER.error("media id must be spotify uri")
|
self._spotify.start_playback(**kwargs)
|
||||||
|
|
||||||
|
@spotify_exception_handler
|
||||||
|
def select_source(self, source: str) -> None:
|
||||||
|
"""Select playback device."""
|
||||||
|
for device in self._devices:
|
||||||
|
if device["name"] == source:
|
||||||
|
self._spotify.transfer_playback(
|
||||||
|
device["id"], self.state == STATE_PLAYING
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
@spotify_exception_handler
|
||||||
|
def set_shuffle(self, shuffle: bool) -> None:
|
||||||
|
"""Enable/Disable shuffle mode."""
|
||||||
|
self._spotify.shuffle(shuffle)
|
||||||
|
|
||||||
|
@spotify_exception_handler
|
||||||
|
def update(self) -> None:
|
||||||
|
"""Update state and attributes."""
|
||||||
|
if not self.enabled:
|
||||||
return
|
return
|
||||||
self._player.start_playback(**kwargs)
|
|
||||||
|
|
||||||
def play_playlist(self, media_id, random_song):
|
if not self._session.valid_token or self._spotify is None:
|
||||||
"""Play random music in a playlist."""
|
run_coroutine_threadsafe(
|
||||||
if not media_id.startswith("spotify:"):
|
self._session.async_ensure_token_valid(), self.hass.loop
|
||||||
_LOGGER.error("media id must be spotify playlist uri")
|
).result()
|
||||||
return
|
self._spotify = Spotify(auth=self._session.token["access_token"])
|
||||||
kwargs = {"context_uri": media_id}
|
|
||||||
if random_song:
|
|
||||||
results = self._player.user_playlist_tracks("me", media_id)
|
|
||||||
position = random.randint(0, results["total"] - 1)
|
|
||||||
kwargs["offset"] = {"position": position}
|
|
||||||
self._player.start_playback(**kwargs)
|
|
||||||
|
|
||||||
@property
|
current = self._spotify.current_playback()
|
||||||
def name(self):
|
self._currently_playing = current or {}
|
||||||
"""Return the name."""
|
|
||||||
return self._name
|
|
||||||
|
|
||||||
@property
|
self._playlist = None
|
||||||
def icon(self):
|
context = self._currently_playing.get("context")
|
||||||
"""Return the icon."""
|
if context is not None and context["type"] == MEDIA_TYPE_PLAYLIST:
|
||||||
return ICON
|
self._playlist = self._spotify.playlist(current["context"]["uri"])
|
||||||
|
|
||||||
@property
|
devices = self._spotify.devices() or {}
|
||||||
def state(self):
|
self._devices = devices.get("devices", [])
|
||||||
"""Return the playback state."""
|
|
||||||
return self._state
|
|
||||||
|
|
||||||
@property
|
|
||||||
def volume_level(self):
|
|
||||||
"""Return the device volume."""
|
|
||||||
return self._volume
|
|
||||||
|
|
||||||
@property
|
|
||||||
def shuffle(self):
|
|
||||||
"""Shuffling state."""
|
|
||||||
return self._shuffle
|
|
||||||
|
|
||||||
@property
|
|
||||||
def source_list(self):
|
|
||||||
"""Return a list of source devices."""
|
|
||||||
if self._devices:
|
|
||||||
return list(self._devices.keys())
|
|
||||||
|
|
||||||
@property
|
|
||||||
def source(self):
|
|
||||||
"""Return the current playback device."""
|
|
||||||
return self._current_device
|
|
||||||
|
|
||||||
@property
|
|
||||||
def media_content_id(self):
|
|
||||||
"""Return the media URL."""
|
|
||||||
return self._uri
|
|
||||||
|
|
||||||
@property
|
|
||||||
def media_image_url(self):
|
|
||||||
"""Return the media image URL."""
|
|
||||||
return self._image_url
|
|
||||||
|
|
||||||
@property
|
|
||||||
def media_artist(self):
|
|
||||||
"""Return the media artist."""
|
|
||||||
return self._artist
|
|
||||||
|
|
||||||
@property
|
|
||||||
def media_album_name(self):
|
|
||||||
"""Return the media album."""
|
|
||||||
return self._album
|
|
||||||
|
|
||||||
@property
|
|
||||||
def media_title(self):
|
|
||||||
"""Return the media title."""
|
|
||||||
return self._title
|
|
||||||
|
|
||||||
@property
|
|
||||||
def supported_features(self):
|
|
||||||
"""Return the media player features that are supported."""
|
|
||||||
if self._user is not None and self._user["product"] == "premium":
|
|
||||||
return SUPPORT_SPOTIFY
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def media_content_type(self):
|
|
||||||
"""Return the media type."""
|
|
||||||
return MEDIA_TYPE_MUSIC
|
|
||||||
|
|
18
homeassistant/components/spotify/strings.json
Normal file
18
homeassistant/components/spotify/strings.json
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"pick_implementation": {
|
||||||
|
"title": "Pick Authentication Method"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"create_entry": {
|
||||||
|
"default": "Successfully authenticated with Spotify."
|
||||||
|
},
|
||||||
|
"title": "Spotify"
|
||||||
|
}
|
||||||
|
}
|
|
@ -78,6 +78,7 @@ FLOWS = [
|
||||||
"soma",
|
"soma",
|
||||||
"somfy",
|
"somfy",
|
||||||
"sonos",
|
"sonos",
|
||||||
|
"spotify",
|
||||||
"starline",
|
"starline",
|
||||||
"tellduslive",
|
"tellduslive",
|
||||||
"tesla",
|
"tesla",
|
||||||
|
|
|
@ -27,6 +27,9 @@ ZEROCONF = {
|
||||||
"_printer._tcp.local.": [
|
"_printer._tcp.local.": [
|
||||||
"brother"
|
"brother"
|
||||||
],
|
],
|
||||||
|
"_spotify-connect._tcp.local.": [
|
||||||
|
"spotify"
|
||||||
|
],
|
||||||
"_viziocast._tcp.local.": [
|
"_viziocast._tcp.local.": [
|
||||||
"vizio"
|
"vizio"
|
||||||
],
|
],
|
||||||
|
|
|
@ -1884,7 +1884,7 @@ spiderpy==1.3.1
|
||||||
spotcrime==1.0.4
|
spotcrime==1.0.4
|
||||||
|
|
||||||
# homeassistant.components.spotify
|
# homeassistant.components.spotify
|
||||||
spotipy-homeassistant==2.4.4.dev1
|
spotipy==2.7.1
|
||||||
|
|
||||||
# homeassistant.components.recorder
|
# homeassistant.components.recorder
|
||||||
# homeassistant.components.sql
|
# homeassistant.components.sql
|
||||||
|
|
|
@ -605,6 +605,9 @@ somecomfort==0.5.2
|
||||||
# homeassistant.components.marytts
|
# homeassistant.components.marytts
|
||||||
speak2mary==1.4.0
|
speak2mary==1.4.0
|
||||||
|
|
||||||
|
# homeassistant.components.spotify
|
||||||
|
spotipy==2.7.1
|
||||||
|
|
||||||
# homeassistant.components.recorder
|
# homeassistant.components.recorder
|
||||||
# homeassistant.components.sql
|
# homeassistant.components.sql
|
||||||
sqlalchemy==1.3.13
|
sqlalchemy==1.3.13
|
||||||
|
|
1
tests/components/spotify/__init__.py
Normal file
1
tests/components/spotify/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for the Spotify integration."""
|
139
tests/components/spotify/test_config_flow.py
Normal file
139
tests/components/spotify/test_config_flow.py
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
"""Tests for the Spotify config flow."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from spotipy import SpotifyException
|
||||||
|
|
||||||
|
from homeassistant import data_entry_flow, setup
|
||||||
|
from homeassistant.components.spotify.const import (
|
||||||
|
CONF_CLIENT_ID,
|
||||||
|
CONF_CLIENT_SECRET,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
|
||||||
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_abort_if_no_configuration(hass):
|
||||||
|
"""Check flow aborts when no configuration is present."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "missing_configuration"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_ZEROCONF}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "missing_configuration"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zeroconf_abort_if_existing_entry(hass):
|
||||||
|
"""Check zeroconf flow aborts when an entry already exist."""
|
||||||
|
MockConfigEntry(domain=DOMAIN).add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_ZEROCONF}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_flow(hass, aiohttp_client, aioclient_mock):
|
||||||
|
"""Check a full flow."""
|
||||||
|
assert await setup.async_setup_component(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
{
|
||||||
|
DOMAIN: {CONF_CLIENT_ID: "client", CONF_CLIENT_SECRET: "secret"},
|
||||||
|
"http": {"base_url": "https://example.com"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]})
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP
|
||||||
|
assert result["url"] == (
|
||||||
|
"https://accounts.spotify.com/authorize"
|
||||||
|
"?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"
|
||||||
|
)
|
||||||
|
|
||||||
|
client = await aiohttp_client(hass.http.app)
|
||||||
|
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||||
|
assert resp.status == 200
|
||||||
|
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||||
|
|
||||||
|
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"):
|
||||||
|
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_abort_if_spotify_error(hass, aiohttp_client, aioclient_mock):
|
||||||
|
"""Check Spotify errors causes flow to abort."""
|
||||||
|
await setup.async_setup_component(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
{
|
||||||
|
DOMAIN: {CONF_CLIENT_ID: "client", CONF_CLIENT_SECRET: "secret"},
|
||||||
|
"http": {"base_url": "https://example.com"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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.current_user",
|
||||||
|
side_effect=SpotifyException(400, -1, "message"),
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "connection_error"
|
Loading…
Add table
Reference in a new issue