diff --git a/.coveragerc b/.coveragerc index 4c3d883cae4..9cc8101cc7c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -281,6 +281,7 @@ omit = homeassistant/components/media_player/samsungtv.py homeassistant/components/media_player/snapcast.py homeassistant/components/media_player/sonos.py + homeassistant/components/media_player/spotify.py homeassistant/components/media_player/squeezebox.py homeassistant/components/media_player/vlc.py homeassistant/components/media_player/volumio.py diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py new file mode 100644 index 00000000000..ce4230bf92c --- /dev/null +++ b/homeassistant/components/media_player/spotify.py @@ -0,0 +1,286 @@ +""" +Support for interacting with Spotify Connect. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.spotify/ +""" + +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.loader import get_component +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.media_player import ( + MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, SUPPORT_VOLUME_SET, + SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_NEXT_TRACK, + SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, PLATFORM_SCHEMA, + MediaPlayerDevice) +from homeassistant.const import ( + CONF_NAME, STATE_PLAYING, STATE_PAUSED, STATE_IDLE, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv + + +COMMIT = '544614f4b1d508201d363e84e871f86c90aa26b2' +REQUIREMENTS = ['https://github.com/happyleavesaoc/spotipy/' + 'archive/%s.zip#spotipy==2.4.4' % COMMIT] + +DEPENDENCIES = ['http'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_SPOTIFY = SUPPORT_VOLUME_SET | SUPPORT_PAUSE | SUPPORT_PLAY |\ + SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | SUPPORT_SELECT_SOURCE |\ + SUPPORT_PLAY_MEDIA + +SCOPE = 'user-read-playback-state user-modify-playback-state' +DEFAULT_CACHE_PATH = '.spotify-token-cache' +AUTH_CALLBACK_PATH = '/api/spotify' +AUTH_CALLBACK_NAME = 'api:spotify' +ICON = 'mdi:spotify' +DEFAULT_NAME = 'Spotify' +DOMAIN = 'spotify' +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' +CONF_CACHE_PATH = 'cache_path' +CONFIGURATOR_LINK_NAME = 'Link Spotify account' +CONFIGURATOR_SUBMIT_CAPTION = 'I authorized successfully' +CONFIGURATOR_DESCRIPTION = 'To link your Spotify account, ' \ + 'click the link, login, and authorize:' + +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 +}) + +SCAN_INTERVAL = timedelta(seconds=30) + + +def request_configuration(hass, config, add_devices, oauth): + """Request Spotify authorization.""" + configurator = get_component('configurator') + hass.data[DOMAIN] = configurator.request_config( + hass, DEFAULT_NAME, lambda _: None, + link_name=CONFIGURATOR_LINK_NAME, + link_url=oauth.get_authorize_url(), + description=CONFIGURATOR_DESCRIPTION, + submit_caption=CONFIGURATOR_SUBMIT_CAPTION) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Spotify platform.""" + import spotipy.oauth2 + callback_url = '{}{}'.format(hass.config.api.base_url, AUTH_CALLBACK_PATH) + cache = config.get(CONF_CACHE_PATH, hass.config.path(DEFAULT_CACHE_PATH)) + 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_devices, oauth)) + request_configuration(hass, config, add_devices, oauth) + return + if hass.data.get(DOMAIN): + configurator = get_component('configurator') + configurator.request_done(hass.data.get(DOMAIN)) + del hass.data[DOMAIN] + player = SpotifyMediaPlayer(oauth, config.get(CONF_NAME, DEFAULT_NAME)) + add_devices([player], True) + + +class SpotifyAuthCallbackView(HomeAssistantView): + """Spotify Authorization Callback View.""" + + requires_auth = False + url = AUTH_CALLBACK_PATH + name = AUTH_CALLBACK_NAME + + def __init__(self, config, add_devices, oauth): + """Initialize.""" + self.config = config + self.add_devices = add_devices + self.oauth = oauth + + @callback + def get(self, request): + """Receive authorization token.""" + hass = request.app['hass'] + self.oauth.get_access_token(request.GET['code']) + hass.async_add_job(setup_platform, hass, self.config, self.add_devices) + + +class SpotifyMediaPlayer(MediaPlayerDevice): + """Representation of a Spotify controller.""" + + def __init__(self, oauth, name): + """Initialize.""" + self._name = name + self._oauth = oauth + self._album = None + self._title = None + self._artist = None + self._uri = None + self._image_url = None + self._state = STATE_UNKNOWN + self._current_device = None + self._devices = None + self._volume = None + self._player = None + self._token_info = self._oauth.get_cached_token() + + def refresh_spotify_instance(self): + """Fetch a new spotify instance.""" + import spotipy + token_refreshed = False + need_token = (self._token_info is None or + self._oauth.is_token_expired(self._token_info)) + if need_token: + new_token = \ + self._oauth.refresh_access_token( + self._token_info['refresh_token']) + self._token_info = new_token + token_refreshed = True + if self._player is None or token_refreshed: + self._player = \ + spotipy.Spotify(auth=self._token_info.get('access_token')) + + def update(self): + """Update state and attributes.""" + self.refresh_spotify_instance() + # Available devices + devices = self._player.devices().get('devices') + if devices is not None: + self._devices = {device.get('name'): device.get('id') + for device in devices} + # 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 = current.get('uri') + self._image_url = item.get('album').get('images')[0].get('url') + # Playing state + self._state = STATE_PAUSED + if current.get('is_playing'): + self._state = STATE_PLAYING + 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): + """Set the volume level.""" + self._player.volume(int(volume * 100)) + + 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.""" + self._player.start_playback() + + def media_pause(self): + """Pause playback.""" + self._player.pause_playback() + + def select_source(self, source): + """Select playback device.""" + self._player.transfer_playback(self._devices[source]) + + def play_media(self, media_type, media_id, **kwargs): + """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 + if not media_id.startswith('spotify:'): + _LOGGER.error('media id must be spotify uri') + return + self._player.start_playback(**kwargs) + + @property + def name(self): + """Name.""" + return self._name + + @property + def icon(self): + """Icon.""" + return ICON + + @property + def state(self): + """Playback state.""" + return self._state + + @property + def volume_level(self): + """Device volume.""" + return self._volume + + @property + def source_list(self): + """Playback devices.""" + return list(self._devices.keys()) + + @property + def source(self): + """Current playback device.""" + return self._current_device + + @property + def media_content_id(self): + """Media URL.""" + return self._uri + + @property + def media_image_url(self): + """Media image url.""" + return self._image_url + + @property + def media_artist(self): + """Media artist.""" + return self._artist + + @property + def media_album_name(self): + """Media album.""" + return self._album + + @property + def media_title(self): + """Media title.""" + return self._title + + @property + def supported_features(self): + """Media player features that are supported.""" + return SUPPORT_SPOTIFY diff --git a/requirements_all.txt b/requirements_all.txt index 43ab2a051e4..b737eb5e10b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -264,6 +264,9 @@ https://github.com/bashwork/pymodbus/archive/d7fc4f1cc975631e0a9011390e8017f64b6 # homeassistant.components.lutron_caseta https://github.com/gurumitts/pylutron-caseta/archive/v0.2.6.zip#pylutron-caseta==v0.2.6 +# homeassistant.components.media_player.spotify +https://github.com/happyleavesaoc/spotipy/archive/544614f4b1d508201d363e84e871f86c90aa26b2.zip#spotipy==2.4.4 + # homeassistant.components.netatmo https://github.com/jabesq/netatmo-api-python/archive/v0.9.1.zip#lnetatmo==0.9.1