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:
cgtobi 2020-08-27 17:00:36 +02:00 committed by GitHub
parent 27f3c0a302
commit c8d49a8adf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 390 additions and 21 deletions

View File

@ -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"

View File

@ -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)
) )

View File

@ -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"]}
)

View File

@ -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",
]

View File

@ -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

View File

@ -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." }
} }

View File

@ -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"