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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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