core/homeassistant/components/spotify/media_player.py

729 lines
24 KiB
Python
Raw Normal View History

"""Support for interacting with Spotify Connect."""
2021-03-18 13:31:38 +00:00
from __future__ import annotations
from asyncio import run_coroutine_threadsafe
import datetime as dt
from datetime import timedelta
2018-09-09 12:26:06 +00:00
import logging
import requests
from spotipy import Spotify, SpotifyException
from yarl import URL
2020-09-06 13:52:59 +00:00
from homeassistant.components.media_player import BrowseMedia, MediaPlayerEntity
from homeassistant.components.media_player.const import (
MEDIA_CLASS_ALBUM,
MEDIA_CLASS_ARTIST,
MEDIA_CLASS_DIRECTORY,
MEDIA_CLASS_EPISODE,
MEDIA_CLASS_GENRE,
MEDIA_CLASS_PLAYLIST,
MEDIA_CLASS_PODCAST,
MEDIA_CLASS_TRACK,
MEDIA_TYPE_ALBUM,
MEDIA_TYPE_ARTIST,
MEDIA_TYPE_EPISODE,
2019-07-31 19:25:30 +00:00
MEDIA_TYPE_MUSIC,
MEDIA_TYPE_PLAYLIST,
MEDIA_TYPE_TRACK,
REPEAT_MODE_ALL,
REPEAT_MODE_OFF,
REPEAT_MODE_ONE,
SUPPORT_BROWSE_MEDIA,
2019-07-31 19:25:30 +00:00
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
SUPPORT_PLAY,
SUPPORT_PLAY_MEDIA,
SUPPORT_PREVIOUS_TRACK,
SUPPORT_REPEAT_SET,
SUPPORT_SEEK,
2019-07-31 19:25:30 +00:00
SUPPORT_SELECT_SOURCE,
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,
CONF_NAME,
STATE_IDLE,
STATE_PAUSED,
STATE_PLAYING,
2019-07-31 19:25:30 +00:00
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
2020-04-26 19:25:58 +00:00
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.dt import utc_from_timestamp
2018-09-09 12:26:06 +00:00
from .const import (
DATA_SPOTIFY_CLIENT,
DATA_SPOTIFY_ME,
DATA_SPOTIFY_SESSION,
DOMAIN,
SPOTIFY_SCOPES,
)
_LOGGER = logging.getLogger(__name__)
2018-09-09 12:26:06 +00:00
SCAN_INTERVAL = timedelta(seconds=30)
2019-07-31 19:25:30 +00:00
SUPPORT_SPOTIFY = (
SUPPORT_BROWSE_MEDIA
| SUPPORT_NEXT_TRACK
2019-07-31 19:25:30 +00:00
| SUPPORT_PAUSE
| SUPPORT_PLAY
| SUPPORT_PLAY_MEDIA
2019-07-31 19:25:30 +00:00
| SUPPORT_PREVIOUS_TRACK
| SUPPORT_REPEAT_SET
| SUPPORT_SEEK
2019-07-31 19:25:30 +00:00
| SUPPORT_SELECT_SOURCE
| SUPPORT_SHUFFLE_SET
| SUPPORT_VOLUME_SET
2019-07-31 19:25:30 +00:00
)
REPEAT_MODE_MAPPING_TO_HA = {
"context": REPEAT_MODE_ALL,
"off": REPEAT_MODE_OFF,
"track": REPEAT_MODE_ONE,
}
REPEAT_MODE_MAPPING_TO_SPOTIFY = {
value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items()
}
BROWSE_LIMIT = 48
MEDIA_TYPE_SHOW = "show"
PLAYABLE_MEDIA_TYPES = [
MEDIA_TYPE_PLAYLIST,
MEDIA_TYPE_ALBUM,
MEDIA_TYPE_ARTIST,
MEDIA_TYPE_EPISODE,
MEDIA_TYPE_SHOW,
MEDIA_TYPE_TRACK,
]
LIBRARY_MAP = {
"current_user_playlists": "Playlists",
"current_user_followed_artists": "Artists",
"current_user_saved_albums": "Albums",
"current_user_saved_tracks": "Tracks",
"current_user_saved_shows": "Podcasts",
"current_user_recently_played": "Recently played",
"current_user_top_artists": "Top Artists",
"current_user_top_tracks": "Top Tracks",
"categories": "Categories",
"featured_playlists": "Featured Playlists",
"new_releases": "New Releases",
}
CONTENT_TYPE_MEDIA_CLASS = {
"current_user_playlists": {
"parent": MEDIA_CLASS_DIRECTORY,
"children": MEDIA_CLASS_PLAYLIST,
},
"current_user_followed_artists": {
"parent": MEDIA_CLASS_DIRECTORY,
"children": MEDIA_CLASS_ARTIST,
},
"current_user_saved_albums": {
"parent": MEDIA_CLASS_DIRECTORY,
"children": MEDIA_CLASS_ALBUM,
},
"current_user_saved_tracks": {
"parent": MEDIA_CLASS_DIRECTORY,
"children": MEDIA_CLASS_TRACK,
},
"current_user_saved_shows": {
"parent": MEDIA_CLASS_DIRECTORY,
"children": MEDIA_CLASS_PODCAST,
},
"current_user_recently_played": {
"parent": MEDIA_CLASS_DIRECTORY,
"children": MEDIA_CLASS_TRACK,
},
"current_user_top_artists": {
"parent": MEDIA_CLASS_DIRECTORY,
"children": MEDIA_CLASS_ARTIST,
},
"current_user_top_tracks": {
"parent": MEDIA_CLASS_DIRECTORY,
"children": MEDIA_CLASS_TRACK,
},
"featured_playlists": {
"parent": MEDIA_CLASS_DIRECTORY,
"children": MEDIA_CLASS_PLAYLIST,
},
"categories": {"parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_GENRE},
"category_playlists": {
"parent": MEDIA_CLASS_DIRECTORY,
"children": MEDIA_CLASS_PLAYLIST,
},
"new_releases": {"parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_ALBUM},
MEDIA_TYPE_PLAYLIST: {
"parent": MEDIA_CLASS_PLAYLIST,
"children": MEDIA_CLASS_TRACK,
},
MEDIA_TYPE_ALBUM: {"parent": MEDIA_CLASS_ALBUM, "children": MEDIA_CLASS_TRACK},
MEDIA_TYPE_ARTIST: {"parent": MEDIA_CLASS_ARTIST, "children": MEDIA_CLASS_ALBUM},
MEDIA_TYPE_EPISODE: {"parent": MEDIA_CLASS_EPISODE, "children": None},
MEDIA_TYPE_SHOW: {"parent": MEDIA_CLASS_PODCAST, "children": MEDIA_CLASS_EPISODE},
MEDIA_TYPE_TRACK: {"parent": MEDIA_CLASS_TRACK, "children": None},
}
class MissingMediaInformation(BrowseError):
"""Missing media required information."""
class UnknownMediaType(BrowseError):
"""Unknown media type."""
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Spotify based on a config entry."""
spotify = SpotifyMediaPlayer(
hass.data[DOMAIN][entry.entry_id][DATA_SPOTIFY_SESSION],
hass.data[DOMAIN][entry.entry_id][DATA_SPOTIFY_CLIENT],
hass.data[DOMAIN][entry.entry_id][DATA_SPOTIFY_ME],
entry.data[CONF_ID],
entry.data[CONF_NAME],
2019-07-31 19:25:30 +00:00
)
async_add_entities([spotify], True)
def spotify_exception_handler(func):
"""Decorate Spotify calls to handle Spotify exception.
2018-09-09 12:26:06 +00:00
A decorator that wraps the passed in function, catches Spotify errors,
aiohttp exceptions and handles the availability of the media player.
"""
def wrapper(self, *args, **kwargs):
2021-09-18 11:52:59 +00:00
# pylint: disable=protected-access
try:
result = func(self, *args, **kwargs)
self._attr_available = True
return result
except requests.RequestException:
self._attr_available = False
except SpotifyException as exc:
self._attr_available = False
if exc.reason == "NO_ACTIVE_DEVICE":
raise HomeAssistantError("No active playback device found") from None
return wrapper
class SpotifyMediaPlayer(MediaPlayerEntity):
"""Representation of a Spotify controller."""
_attr_icon = "mdi:spotify"
_attr_media_content_type = MEDIA_TYPE_MUSIC
_attr_media_image_remotely_accessible = False
2020-04-26 19:25:58 +00:00
def __init__(
self,
session: OAuth2Session,
spotify: Spotify,
me: dict,
user_id: str,
name: str,
) -> None:
"""Initialize."""
self._id = user_id
self._me = me
self._name = f"Spotify {name}"
self._session = session
self._spotify = spotify
self._scope_ok = set(session.token["scope"].split(" ")).issuperset(
SPOTIFY_SCOPES
)
2021-03-18 13:31:38 +00:00
self._currently_playing: dict | None = {}
self._devices: list[dict] | None = []
self._playlist: dict | None = None
self._attr_name = self._name
self._attr_unique_id = user_id
@property
def device_info(self) -> DeviceInfo:
"""Return device information about this entity."""
model = "Spotify Free"
if self._me is not None:
product = self._me["product"]
model = f"Spotify {product}"
return DeviceInfo(
identifiers={(DOMAIN, self._id)},
manufacturer="Spotify AB",
model=model,
name=self._name,
entry_type="service",
configuration_url="https://open.spotify.com",
)
@property
2021-03-18 13:31:38 +00:00
def state(self) -> str | None:
"""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
2021-03-18 13:31:38 +00:00
def volume_level(self) -> float | None:
"""Return the device volume."""
return self._currently_playing.get("device", {}).get("volume_percent", 0) / 100
@property
2021-03-18 13:31:38 +00:00
def media_content_id(self) -> str | None:
"""Return the media URL."""
item = self._currently_playing.get("item") or {}
return item.get("uri")
@property
2021-03-18 13:31:38 +00:00
def media_duration(self) -> int | None:
"""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
2021-03-18 13:31:38 +00:00
def media_position(self) -> str | None:
"""Position of current playing media in seconds."""
if not self._currently_playing:
return None
return self._currently_playing["progress_ms"] / 1000
@property
2021-03-18 13:31:38 +00:00
def media_position_updated_at(self) -> dt.datetime | None:
"""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
2021-03-18 13:31:38 +00:00
def media_image_url(self) -> str | None:
"""Return the media image URL."""
if (
self._currently_playing.get("item") is None
or not self._currently_playing["item"]["album"]["images"]
):
return None
return fetch_image_url(self._currently_playing["item"]["album"])
@property
2021-03-18 13:31:38 +00:00
def media_title(self) -> str | None:
"""Return the media title."""
item = self._currently_playing.get("item") or {}
return item.get("name")
@property
2021-03-18 13:31:38 +00:00
def media_artist(self) -> str | None:
"""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"]
)
@property
2021-03-18 13:31:38 +00:00
def media_album_name(self) -> str | None:
"""Return the media album."""
if self._currently_playing.get("item") is None:
return None
return self._currently_playing["item"]["album"]["name"]
@property
2021-03-18 13:31:38 +00:00
def media_track(self) -> int | None:
"""Track number of current playing media, music track only."""
item = self._currently_playing.get("item") or {}
return item.get("track_number")
@property
def media_playlist(self):
"""Title of Playlist currently playing."""
if self._playlist is None:
return None
return self._playlist["name"]
@property
2021-03-18 13:31:38 +00:00
def source(self) -> str | None:
"""Return the current playback device."""
return self._currently_playing.get("device", {}).get("name")
@property
2021-03-18 13:31:38 +00:00
def source_list(self) -> list[str] | None:
"""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
2021-03-18 13:31:38 +00:00
def repeat(self) -> str | None:
"""Return current repeat mode."""
repeat_state = self._currently_playing.get("repeat_state")
return REPEAT_MODE_MAPPING_TO_HA.get(repeat_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."""
self._spotify.volume(int(volume * 100))
@spotify_exception_handler
def media_play(self) -> None:
"""Start or resume playback."""
self._spotify.start_playback()
@spotify_exception_handler
def media_pause(self) -> None:
"""Pause playback."""
self._spotify.pause_playback()
@spotify_exception_handler
def media_previous_track(self) -> None:
"""Skip to previous track."""
self._spotify.previous_track()
@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."""
kwargs = {}
# Spotify can't handle URI's with query strings or anchors
# 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 in (MEDIA_TYPE_TRACK, MEDIA_TYPE_EPISODE, MEDIA_TYPE_MUSIC):
kwargs["uris"] = [media_id]
elif media_type in PLAYABLE_MEDIA_TYPES:
kwargs["context_uri"] = media_id
else:
_LOGGER.error("Media type %s is not supported", media_type)
return
if not self._currently_playing.get("device") and self._devices:
kwargs["device_id"] = self._devices[0].get("id")
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 set_repeat(self, repeat: str) -> None:
"""Set repeat mode."""
if repeat not in REPEAT_MODE_MAPPING_TO_SPOTIFY:
raise ValueError(f"Unsupported repeat mode: {repeat}")
self._spotify.repeat(REPEAT_MODE_MAPPING_TO_SPOTIFY[repeat])
@spotify_exception_handler
def update(self) -> None:
"""Update state and attributes."""
if not self.enabled:
return
if not self._session.valid_token or self._spotify is None:
run_coroutine_threadsafe(
self._session.async_ensure_token_valid(), self.hass.loop
).result()
self._spotify = Spotify(auth=self._session.token["access_token"])
current = self._spotify.current_playback()
self._currently_playing = current or {}
self._playlist = None
context = self._currently_playing.get("context")
if context is not None and context["type"] == MEDIA_TYPE_PLAYLIST:
self._playlist = self._spotify.playlist(current["context"]["uri"])
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:
_LOGGER.debug(
"Spotify scopes are not set correctly, this can impact features such as media browsing"
)
raise NotImplementedError
2021-07-29 23:20:03 +00:00
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, self._me, payload
)
if response is None:
raise BrowseError(
f"Media not found: {media_content_type} / {media_content_id}"
)
return response
def build_item_response(spotify, user, payload): # noqa: C901
"""Create response payload for the provided media query."""
media_content_type = payload["media_content_type"]
media_content_id = payload["media_content_id"]
title = None
image = None
if media_content_type == "current_user_playlists":
media = spotify.current_user_playlists(limit=BROWSE_LIMIT)
items = media.get("items", [])
elif media_content_type == "current_user_followed_artists":
media = spotify.current_user_followed_artists(limit=BROWSE_LIMIT)
items = media.get("artists", {}).get("items", [])
elif media_content_type == "current_user_saved_albums":
media = spotify.current_user_saved_albums(limit=BROWSE_LIMIT)
items = [item["album"] for item in media.get("items", [])]
elif media_content_type == "current_user_saved_tracks":
media = spotify.current_user_saved_tracks(limit=BROWSE_LIMIT)
items = [item["track"] for item in media.get("items", [])]
elif media_content_type == "current_user_saved_shows":
media = spotify.current_user_saved_shows(limit=BROWSE_LIMIT)
items = [item["show"] for item in media.get("items", [])]
elif media_content_type == "current_user_recently_played":
media = spotify.current_user_recently_played(limit=BROWSE_LIMIT)
items = [item["track"] for item in media.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 == "current_user_top_tracks":
media = spotify.current_user_top_tracks(limit=BROWSE_LIMIT)
items = media.get("items", [])
elif media_content_type == "featured_playlists":
media = spotify.featured_playlists(country=user["country"], limit=BROWSE_LIMIT)
items = media.get("playlists", {}).get("items", [])
elif media_content_type == "categories":
media = spotify.categories(country=user["country"], limit=BROWSE_LIMIT)
items = media.get("categories", {}).get("items", [])
elif media_content_type == "category_playlists":
media = spotify.category_playlists(
category_id=media_content_id,
country=user["country"],
limit=BROWSE_LIMIT,
)
category = spotify.category(media_content_id, country=user["country"])
title = category.get("name")
image = fetch_image_url(category, key="icons")
items = media.get("playlists", {}).get("items", [])
elif media_content_type == "new_releases":
media = spotify.new_releases(country=user["country"], limit=BROWSE_LIMIT)
items = media.get("albums", {}).get("items", [])
elif media_content_type == MEDIA_TYPE_PLAYLIST:
media = spotify.playlist(media_content_id)
items = [item["track"] for item in media.get("tracks", {}).get("items", [])]
elif media_content_type == MEDIA_TYPE_ALBUM:
media = spotify.album(media_content_id)
items = media.get("tracks", {}).get("items", [])
elif media_content_type == MEDIA_TYPE_ARTIST:
media = spotify.artist_albums(media_content_id, limit=BROWSE_LIMIT)
artist = spotify.artist(media_content_id)
title = artist.get("name")
image = fetch_image_url(artist)
items = media.get("items", [])
elif media_content_type == MEDIA_TYPE_SHOW:
media = spotify.show_episodes(media_content_id, limit=BROWSE_LIMIT)
show = spotify.show(media_content_id)
title = show.get("name")
image = fetch_image_url(show)
items = media.get("items", [])
else:
media = None
2020-09-06 13:52:59 +00:00
items = []
if media is None:
return None
try:
media_class = CONTENT_TYPE_MEDIA_CLASS[media_content_type]
except KeyError:
_LOGGER.debug("Unknown media type received: %s", media_content_type)
return None
if media_content_type == "categories":
media_item = BrowseMedia(
title=LIBRARY_MAP.get(media_content_id),
media_class=media_class["parent"],
children_media_class=media_class["children"],
media_content_id=media_content_id,
media_content_type=media_content_type,
can_play=False,
can_expand=True,
children=[],
)
for item in items:
try:
item_id = item["id"]
except KeyError:
_LOGGER.debug("Missing ID for media item: %s", item)
continue
media_item.children.append(
BrowseMedia(
title=item.get("name"),
media_class=MEDIA_CLASS_PLAYLIST,
children_media_class=MEDIA_CLASS_TRACK,
media_content_id=item_id,
media_content_type="category_playlists",
thumbnail=fetch_image_url(item, key="icons"),
can_play=False,
can_expand=True,
)
)
return media_item
2020-09-06 13:52:59 +00:00
if title is None:
if "name" in media:
title = media.get("name")
else:
title = LIBRARY_MAP.get(payload["media_content_id"])
params = {
2020-09-06 13:52:59 +00:00
"title": title,
"media_class": media_class["parent"],
"children_media_class": media_class["children"],
"media_content_id": media_content_id,
"media_content_type": media_content_type,
"can_play": media_content_type in PLAYABLE_MEDIA_TYPES,
"children": [],
2020-09-06 13:52:59 +00:00
"can_expand": True,
}
for item in items:
try:
params["children"].append(item_payload(item))
except (MissingMediaInformation, UnknownMediaType):
continue
if "images" in media:
params["thumbnail"] = fetch_image_url(media)
elif image:
params["thumbnail"] = image
return BrowseMedia(**params)
def item_payload(item):
"""
Create response payload for a single media item.
Used by async_browse_media.
"""
try:
media_type = item["type"]
media_id = item["uri"]
except KeyError as err:
_LOGGER.debug("Missing type or URI for media item: %s", item)
raise MissingMediaInformation from err
try:
media_class = CONTENT_TYPE_MEDIA_CLASS[media_type]
except KeyError as err:
_LOGGER.debug("Unknown media type received: %s", media_type)
raise UnknownMediaType from err
can_expand = media_type not in [
MEDIA_TYPE_TRACK,
MEDIA_TYPE_EPISODE,
]
payload = {
"title": item.get("name"),
"media_class": media_class["parent"],
"children_media_class": media_class["children"],
"media_content_id": media_id,
"media_content_type": media_type,
"can_play": media_type in PLAYABLE_MEDIA_TYPES,
"can_expand": can_expand,
}
2020-09-06 13:52:59 +00:00
if "images" in item:
payload["thumbnail"] = fetch_image_url(item)
elif MEDIA_TYPE_ALBUM in item:
payload["thumbnail"] = fetch_image_url(item[MEDIA_TYPE_ALBUM])
return BrowseMedia(**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_class": MEDIA_CLASS_DIRECTORY,
"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"]}
)
)
response = BrowseMedia(**library_info)
response.children_media_class = MEDIA_CLASS_DIRECTORY
return response
def fetch_image_url(item, key="images"):
"""Fetch image url."""
try:
return item.get(key, [])[0].get("url")
except IndexError:
return None