2019-04-03 15:40:03 +00:00
|
|
|
"""Support for interacting with Spotify Connect."""
|
2020-01-24 17:47:22 +00:00
|
|
|
from asyncio import run_coroutine_threadsafe
|
|
|
|
import datetime as dt
|
2017-04-20 05:45:12 +00:00
|
|
|
from datetime import timedelta
|
2018-09-09 12:26:06 +00:00
|
|
|
import logging
|
2020-01-24 17:47:22 +00:00
|
|
|
from typing import Any, Callable, Dict, List, Optional
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
from aiohttp import ClientError
|
|
|
|
from spotipy import Spotify, SpotifyException
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
from homeassistant.components.media_player import MediaPlayerDevice
|
2019-02-08 22:18:18 +00:00
|
|
|
from homeassistant.components.media_player.const import (
|
2019-07-31 19:25:30 +00:00
|
|
|
MEDIA_TYPE_MUSIC,
|
|
|
|
MEDIA_TYPE_PLAYLIST,
|
|
|
|
SUPPORT_NEXT_TRACK,
|
|
|
|
SUPPORT_PAUSE,
|
|
|
|
SUPPORT_PLAY,
|
|
|
|
SUPPORT_PLAY_MEDIA,
|
|
|
|
SUPPORT_PREVIOUS_TRACK,
|
2020-01-24 17:47:22 +00:00
|
|
|
SUPPORT_SEEK,
|
2019-07-31 19:25:30 +00:00
|
|
|
SUPPORT_SELECT_SOURCE,
|
|
|
|
SUPPORT_SHUFFLE_SET,
|
|
|
|
SUPPORT_VOLUME_SET,
|
|
|
|
)
|
2020-01-24 17:47:22 +00:00
|
|
|
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
|
|
|
)
|
2020-01-24 17:47:22 +00:00
|
|
|
from homeassistant.core import HomeAssistant
|
|
|
|
from homeassistant.helpers.entity import Entity
|
|
|
|
from homeassistant.util.dt import utc_from_timestamp
|
2018-09-09 12:26:06 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
from .const import DATA_SPOTIFY_CLIENT, DATA_SPOTIFY_ME, DATA_SPOTIFY_SESSION, DOMAIN
|
2019-07-16 02:41:16 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
2019-07-16 02:41:16 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
ICON = "mdi:spotify"
|
2018-09-09 12:26:06 +00:00
|
|
|
|
|
|
|
SCAN_INTERVAL = timedelta(seconds=30)
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
SUPPORT_SPOTIFY = (
|
2020-01-24 17:47:22 +00:00
|
|
|
SUPPORT_NEXT_TRACK
|
2019-07-31 19:25:30 +00:00
|
|
|
| SUPPORT_PAUSE
|
|
|
|
| SUPPORT_PLAY
|
2020-01-24 17:47:22 +00:00
|
|
|
| SUPPORT_PLAY_MEDIA
|
2019-07-31 19:25:30 +00:00
|
|
|
| SUPPORT_PREVIOUS_TRACK
|
2020-01-24 17:47:22 +00:00
|
|
|
| SUPPORT_SEEK
|
2019-07-31 19:25:30 +00:00
|
|
|
| SUPPORT_SELECT_SOURCE
|
|
|
|
| SUPPORT_SHUFFLE_SET
|
2020-01-24 17:47:22 +00:00
|
|
|
| SUPPORT_VOLUME_SET
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
async def async_setup_entry(
|
|
|
|
hass: HomeAssistant,
|
|
|
|
entry: ConfigEntry,
|
|
|
|
async_add_entities: Callable[[List[Entity], bool], None],
|
|
|
|
) -> 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
|
|
|
)
|
2020-01-24 17:47:22 +00:00
|
|
|
async_add_entities([spotify], True)
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
def spotify_exception_handler(func):
|
|
|
|
"""Decorate Spotify calls to handle Spotify exception.
|
2018-09-09 12:26:06 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
A decorator that wraps the passed in function, catches Spotify errors,
|
|
|
|
aiohttp exceptions and handles the availability of the media player.
|
|
|
|
"""
|
2019-07-16 02:41:16 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
def wrapper(self, *args, **kwargs):
|
|
|
|
try:
|
|
|
|
result = func(self, *args, **kwargs)
|
|
|
|
self.player_available = True
|
|
|
|
return result
|
|
|
|
except (SpotifyException, ClientError):
|
|
|
|
self.player_available = False
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
return wrapper
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
|
|
|
|
class SpotifyMediaPlayer(MediaPlayerDevice):
|
|
|
|
"""Representation of a Spotify controller."""
|
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
def __init__(self, session, spotify: Spotify, me: dict, user_id: str, name: str):
|
2017-04-20 05:45:12 +00:00
|
|
|
"""Initialize."""
|
2020-01-24 17:47:22 +00:00
|
|
|
self._id = user_id
|
|
|
|
self._me = me
|
|
|
|
self._name = f"Spotify {name}"
|
|
|
|
self._session = session
|
|
|
|
self._spotify = spotify
|
2017-09-04 18:47:40 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
self._currently_playing: Optional[dict] = {}
|
|
|
|
self._devices: Optional[List[dict]] = []
|
|
|
|
self._playlist: Optional[dict] = None
|
|
|
|
self._spotify: Spotify = None
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
self.player_available = False
|
2017-09-04 18:47:40 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
|
|
|
def name(self) -> str:
|
|
|
|
"""Return the name."""
|
|
|
|
return self._name
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
|
|
|
def icon(self) -> str:
|
|
|
|
"""Return the icon."""
|
|
|
|
return ICON
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
|
|
|
def available(self) -> bool:
|
|
|
|
"""Return True if entity is available."""
|
|
|
|
return self.player_available
|
2017-04-30 19:41:21 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
|
|
|
def unique_id(self) -> str:
|
|
|
|
"""Return the unique ID."""
|
|
|
|
return self._id
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@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,
|
|
|
|
}
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@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
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
|
|
|
def volume_level(self) -> Optional[float]:
|
|
|
|
"""Return the device volume."""
|
|
|
|
return self._currently_playing.get("device", {}).get("volume_percent", 0) / 100
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
|
|
|
def media_content_id(self) -> Optional[str]:
|
|
|
|
"""Return the media URL."""
|
|
|
|
return self._currently_playing.get("item", {}).get("name")
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
|
|
|
def media_content_type(self) -> Optional[str]:
|
|
|
|
"""Return the media type."""
|
|
|
|
return MEDIA_TYPE_MUSIC
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@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
|
2019-07-16 02:41:16 +00:00
|
|
|
|
2017-04-20 05:45:12 +00:00
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
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
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
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)
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
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"]
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
def media_image_remotely_accessible(self) -> bool:
|
|
|
|
"""If the image url is remotely accessible."""
|
|
|
|
return False
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2017-04-30 19:41:21 +00:00
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
def media_title(self) -> Optional[str]:
|
|
|
|
"""Return the media title."""
|
|
|
|
return self._currently_playing.get("item", {}).get("name")
|
2017-04-30 19:41:21 +00:00
|
|
|
|
2017-04-20 05:45:12 +00:00
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
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"]]
|
|
|
|
)
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
def media_album_name(self) -> Optional[str]:
|
|
|
|
"""Return the media album."""
|
|
|
|
if self._currently_playing.get("item") is None:
|
|
|
|
return None
|
|
|
|
return self._currently_playing["item"]["album"]["name"]
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
def media_track(self) -> Optional[int]:
|
|
|
|
"""Track number of current playing media, music track only."""
|
|
|
|
return self._currently_playing.get("item", {}).get("track_number")
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
def media_playlist(self):
|
|
|
|
"""Title of Playlist currently playing."""
|
|
|
|
if self._playlist is None:
|
|
|
|
return None
|
|
|
|
return self._playlist["name"]
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
def source(self) -> Optional[str]:
|
|
|
|
"""Return the current playback device."""
|
|
|
|
return self._currently_playing.get("device", {}).get("name")
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
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]
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
def shuffle(self) -> bool:
|
|
|
|
"""Shuffling state."""
|
|
|
|
return bool(self._currently_playing.get("shuffle_state"))
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
def supported_features(self) -> int:
|
2017-05-02 16:18:47 +00:00
|
|
|
"""Return the media player features that are supported."""
|
2020-01-24 17:47:22 +00:00
|
|
|
if self._me["product"] != "premium":
|
|
|
|
return 0
|
|
|
|
return SUPPORT_SPOTIFY
|
2017-07-07 14:52:11 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@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 = {}
|
|
|
|
|
|
|
|
if media_type == MEDIA_TYPE_MUSIC:
|
|
|
|
kwargs["uris"] = [media_id]
|
|
|
|
elif media_type == MEDIA_TYPE_PLAYLIST:
|
|
|
|
kwargs["context_uri"] = media_id
|
|
|
|
else:
|
|
|
|
_LOGGER.error("Media type %s is not supported", media_type)
|
|
|
|
return
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
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", [])
|