From 651959530c32addea677cc659addff26241a7594 Mon Sep 17 00:00:00 2001 From: Todd Lucas Date: Wed, 19 Jun 2024 19:12:03 -0500 Subject: [PATCH] [ 1.0.28 ] * Added service `get_spotify_connect_devices` that gets information about all available Spotify Connect player devices. * Added service `get_player_now_playing` that gets object properties currently being played on the user's Spotify account. * Added service `player_activate_devices` that activates all static Spotify Connect player devices, and (optionally) switches the active user context to the current user context. * Added service `player_resolve_device_id` that resolves a Spotify Connect device identifier from a specified device id, name, alias id, or alias name. This will ensure that the device id can be found on the network, as well as connect to the device if necessary with the current user context. * Added service `get_player_playback_state` that gets information about the user's current playback state, including track or episode, progress, and active device. * Added extra state attribute `media_context_content_id` that contains the Context Content ID of current playing context if one is active; otherwise, None. * Updated underlying `spotifywebapiPython` package requirement to version 1.0.59. --- CHANGELOG.md | 10 + custom_components/spotifyplus/__init__.py | 182 +++++++- custom_components/spotifyplus/config_flow.py | 63 ++- custom_components/spotifyplus/const.py | 2 + .../spotifyplus/instancedata_spotifyplus.py | 20 +- custom_components/spotifyplus/manifest.json | 11 +- custom_components/spotifyplus/media_player.py | 396 ++++++++++++++++-- custom_components/spotifyplus/services.yaml | 133 ++++++ custom_components/spotifyplus/strings.json | 91 +++- .../spotifyplus/translations/en.json | 91 +++- requirements.txt | 2 +- 11 files changed, 927 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ff85b..73b7ddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.28 ] - 2024/06/14 + + * Added service `get_spotify_connect_devices` that gets information about all available Spotify Connect player devices. + * Added service `get_player_now_playing` that gets object properties currently being played on the user's Spotify account. + * Added service `player_activate_devices` that activates all static Spotify Connect player devices, and (optionally) switches the active user context to the current user context. + * Added service `player_resolve_device_id` that resolves a Spotify Connect device identifier from a specified device id, name, alias id, or alias name. This will ensure that the device id can be found on the network, as well as connect to the device if necessary with the current user context. + * Added service `get_player_playback_state` that gets information about the user's current playback state, including track or episode, progress, and active device. + * Added extra state attribute `media_context_content_id` that contains the Context Content ID of current playing context if one is active; otherwise, None. + * Updated underlying `spotifywebapiPython` package requirement to version 1.0.59. + ###### [ 1.0.27 ] - 2024/06/12 * Added extra state attribute `media_playlist_content_id` that contains the Content ID of current playing playlist context if one is active; otherwise, None. diff --git a/custom_components/spotifyplus/__init__.py b/custom_components/spotifyplus/__init__.py index 06c1f4b..c3f32b7 100644 --- a/custom_components/spotifyplus/__init__.py +++ b/custom_components/spotifyplus/__init__.py @@ -13,9 +13,10 @@ import logging import voluptuous as vol -from spotifywebapipython import SpotifyClient, SpotifyApiError, SpotifyWebApiError, SpotifyWebApiAuthenticationError -from spotifywebapipython.models import Device +from spotifywebapipython import SpotifyClient +from spotifywebapipython.models import Device, SpotifyConnectDevices +from homeassistant.components import zeroconf from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform, CONF_ID @@ -29,6 +30,8 @@ from .appmessages import STAppMessages from .const import ( + CONF_OPTION_DEVICE_PASSWORD, + CONF_OPTION_DEVICE_USERNAME, DOMAIN, SPOTIFY_SCOPES ) @@ -95,6 +98,8 @@ SERVICE_SPOTIFY_GET_CATEGORY_PLAYLISTS:str = 'get_category_playlists' SERVICE_SPOTIFY_GET_FEATURED_PLAYLISTS:str = 'get_featured_playlists' SERVICE_SPOTIFY_GET_PLAYER_DEVICES:str = 'get_player_devices' +SERVICE_SPOTIFY_GET_PLAYER_NOW_PLAYING:str = 'get_player_now_playing' +SERVICE_SPOTIFY_GET_PLAYER_PLAYBACK_STATE:str = 'get_player_playback_state' SERVICE_SPOTIFY_GET_PLAYER_QUEUE_INFO:str = 'get_player_queue_info' SERVICE_SPOTIFY_GET_PLAYER_RECENT_TRACKS:str = 'get_player_recent_tracks' SERVICE_SPOTIFY_GET_PLAYLIST:str = 'get_playlist' @@ -102,12 +107,15 @@ SERVICE_SPOTIFY_GET_SHOW:str = 'get_show' SERVICE_SPOTIFY_GET_SHOW_EPISODES:str = 'get_show_episodes' SERVICE_SPOTIFY_GET_SHOW_FAVORITES:str = 'get_show_favorites' +SERVICE_SPOTIFY_GET_SPOTIFY_CONNECT_DEVICES:str = 'get_spotify_connect_devices' SERVICE_SPOTIFY_GET_TRACK_FAVORITES:str = 'get_track_favorites' SERVICE_SPOTIFY_GET_USERS_TOP_ARTISTS:str = 'get_users_top_artists' SERVICE_SPOTIFY_GET_USERS_TOP_TRACKS:str = 'get_users_top_tracks' +SERVICE_SPOTIFY_PLAYER_ACTIVATE_DEVICES:str = 'player_activate_devices' SERVICE_SPOTIFY_PLAYER_MEDIA_PLAY_CONTEXT:str = 'player_media_play_context' SERVICE_SPOTIFY_PLAYER_MEDIA_PLAY_TRACK_FAVORITES:str = 'player_media_play_track_favorites' SERVICE_SPOTIFY_PLAYER_MEDIA_PLAY_TRACKS:str = 'player_media_play_tracks' +SERVICE_SPOTIFY_PLAYER_RESOLVE_DEVICE_ID:str = 'player_resolve_device_id' SERVICE_SPOTIFY_PLAYER_TRANSFER_PLAYBACK:str = 'player_transfer_playback' SERVICE_SPOTIFY_PLAYLIST_CHANGE:str = 'playlist_change' SERVICE_SPOTIFY_PLAYLIST_COVER_IMAGE_ADD:str = 'playlist_cover_image_add' @@ -253,6 +261,22 @@ } ) +SERVICE_SPOTIFY_GET_PLAYER_NOW_PLAYING_SCHEMA = vol.Schema( + { + vol.Required("entity_id"): cv.entity_id, + vol.Optional("market"): cv.string, + vol.Optional("additional_types"): cv.string, + } +) + +SERVICE_SPOTIFY_GET_PLAYER_PLAYBACK_STATE_SCHEMA = vol.Schema( + { + vol.Required("entity_id"): cv.entity_id, + vol.Optional("market"): cv.string, + vol.Optional("additional_types"): cv.string, + } +) + SERVICE_SPOTIFY_GET_PLAYER_QUEUE_INFO_SCHEMA = vol.Schema( { vol.Required("entity_id"): cv.entity_id, @@ -316,6 +340,12 @@ } ) +SERVICE_SPOTIFY_GET_SPOTIFY_CONNECT_DEVICES_SCHEMA = vol.Schema( + { + vol.Required("entity_id"): cv.entity_id, + } +) + SERVICE_SPOTIFY_GET_TRACK_FAVORITES_SCHEMA = vol.Schema( { vol.Required("entity_id"): cv.entity_id, @@ -346,6 +376,14 @@ } ) +SERVICE_SPOTIFY_PLAYER_ACTIVATE_DEVICES_SCHEMA = vol.Schema( + { + vol.Required("entity_id"): cv.entity_id, + vol.Optional("verify_user_context"): cv.boolean, + vol.Optional("delay", default=0.50): vol.All(vol.Range(min=0,max=10.0)), + } +) + SERVICE_SPOTIFY_PLAYER_MEDIA_PLAY_CONTEXT_SCHEMA = vol.Schema( { vol.Required("entity_id"): cv.entity_id, @@ -375,6 +413,15 @@ } ) +SERVICE_SPOTIFY_PLAYER_RESOLVE_DEVICE_ID_SCHEMA = vol.Schema( + { + vol.Required("entity_id"): cv.entity_id, + vol.Required("device_value"): cv.string, + vol.Optional("verify_user_context"): cv.boolean, + vol.Optional("verify_timeout", default=5.0): vol.All(vol.Range(min=0,max=10.0)), + } +) + SERVICE_SPOTIFY_PLAYER_TRANSFER_PLAYBACK_SCHEMA = vol.Schema( { vol.Required("entity_id"): cv.entity_id, @@ -1001,11 +1048,27 @@ async def service_handle_spotify_serviceresponse(service: ServiceCall) -> Servic elif service.service == SERVICE_SPOTIFY_GET_PLAYER_DEVICES: - # get spotify connect device list. + # get spotify player device list. refresh = service.data.get("refresh") _logsi.LogVerbose(STAppMessages.MSG_SERVICE_EXECUTE % (service.service, entity.name)) response = await hass.async_add_executor_job(entity.service_spotify_get_player_devices, refresh) + elif service.service == SERVICE_SPOTIFY_GET_PLAYER_NOW_PLAYING: + + # get spotify player now playing. + market = service.data.get("market") + additional_types = service.data.get("additional_types") + _logsi.LogVerbose(STAppMessages.MSG_SERVICE_EXECUTE % (service.service, entity.name)) + response = await hass.async_add_executor_job(entity.service_spotify_get_player_now_playing, market, additional_types) + + elif service.service == SERVICE_SPOTIFY_GET_PLAYER_PLAYBACK_STATE: + + # get spotify player playback state. + market = service.data.get("market") + additional_types = service.data.get("additional_types") + _logsi.LogVerbose(STAppMessages.MSG_SERVICE_EXECUTE % (service.service, entity.name)) + response = await hass.async_add_executor_job(entity.service_spotify_get_player_playback_state, market, additional_types) + elif service.service == SERVICE_SPOTIFY_GET_PLAYER_QUEUE_INFO: # get spotify queue info. @@ -1069,6 +1132,12 @@ async def service_handle_spotify_serviceresponse(service: ServiceCall) -> Servic _logsi.LogVerbose(STAppMessages.MSG_SERVICE_EXECUTE % (service.service, entity.name)) response = await hass.async_add_executor_job(entity.service_spotify_get_show_favorites, limit, offset, limit_total) + elif service.service == SERVICE_SPOTIFY_GET_SPOTIFY_CONNECT_DEVICES: + + # get spotify connect device list. + _logsi.LogVerbose(STAppMessages.MSG_SERVICE_EXECUTE % (service.service, entity.name)) + response = await hass.async_add_executor_job(entity.service_spotify_get_spotify_connect_devices) + elif service.service == SERVICE_SPOTIFY_GET_TRACK_FAVORITES: # get spotify album favorites. @@ -1099,6 +1168,23 @@ async def service_handle_spotify_serviceresponse(service: ServiceCall) -> Servic _logsi.LogVerbose(STAppMessages.MSG_SERVICE_EXECUTE % (service.service, entity.name)) response = await hass.async_add_executor_job(entity.service_spotify_get_users_top_tracks, time_range, limit, offset, limit_total) + elif service.service == SERVICE_SPOTIFY_PLAYER_ACTIVATE_DEVICES: + + # activate all spotify connect player devices. + verify_user_context = service.data.get("verify_user_context") + delay = service.data.get("delay") + _logsi.LogVerbose(STAppMessages.MSG_SERVICE_EXECUTE % (service.service, entity.name)) + response = await hass.async_add_executor_job(entity.service_spotify_player_activate_devices, verify_user_context, delay) + + elif service.service == SERVICE_SPOTIFY_PLAYER_RESOLVE_DEVICE_ID: + + # resolve spotify connect player device id. + device_value = service.data.get("device_value") + verify_user_context = service.data.get("verify_user_context") + verify_timeout = service.data.get("verify_timeout") + _logsi.LogVerbose(STAppMessages.MSG_SERVICE_EXECUTE % (service.service, entity.name)) + response = await hass.async_add_executor_job(entity.service_spotify_player_resolve_device_id, device_value, verify_user_context, verify_timeout) + elif service.service == SERVICE_SPOTIFY_PLAYLIST_CREATE: # create a new playlist. @@ -1429,6 +1515,24 @@ def _GetEntityFromServiceData(hass:HomeAssistant, service:ServiceCall, field_id: supports_response=SupportsResponse.ONLY, ) + _logsi.LogObject(SILevel.Verbose, STAppMessages.MSG_SERVICE_REQUEST_REGISTER % SERVICE_SPOTIFY_GET_PLAYER_PLAYBACK_STATE, SERVICE_SPOTIFY_GET_PLAYER_PLAYBACK_STATE_SCHEMA) + hass.services.async_register( + DOMAIN, + SERVICE_SPOTIFY_GET_PLAYER_PLAYBACK_STATE, + service_handle_spotify_serviceresponse, + schema=SERVICE_SPOTIFY_GET_PLAYER_PLAYBACK_STATE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + _logsi.LogObject(SILevel.Verbose, STAppMessages.MSG_SERVICE_REQUEST_REGISTER % SERVICE_SPOTIFY_GET_PLAYER_NOW_PLAYING, SERVICE_SPOTIFY_GET_PLAYER_NOW_PLAYING_SCHEMA) + hass.services.async_register( + DOMAIN, + SERVICE_SPOTIFY_GET_PLAYER_NOW_PLAYING, + service_handle_spotify_serviceresponse, + schema=SERVICE_SPOTIFY_GET_PLAYER_NOW_PLAYING_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + _logsi.LogObject(SILevel.Verbose, STAppMessages.MSG_SERVICE_REQUEST_REGISTER % SERVICE_SPOTIFY_GET_PLAYER_QUEUE_INFO, SERVICE_SPOTIFY_GET_PLAYER_QUEUE_INFO_SCHEMA) hass.services.async_register( DOMAIN, @@ -1492,6 +1596,15 @@ def _GetEntityFromServiceData(hass:HomeAssistant, service:ServiceCall, field_id: supports_response=SupportsResponse.ONLY, ) + _logsi.LogObject(SILevel.Verbose, STAppMessages.MSG_SERVICE_REQUEST_REGISTER % SERVICE_SPOTIFY_GET_SPOTIFY_CONNECT_DEVICES, SERVICE_SPOTIFY_GET_SPOTIFY_CONNECT_DEVICES_SCHEMA) + hass.services.async_register( + DOMAIN, + SERVICE_SPOTIFY_GET_SPOTIFY_CONNECT_DEVICES, + service_handle_spotify_serviceresponse, + schema=SERVICE_SPOTIFY_GET_SPOTIFY_CONNECT_DEVICES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + _logsi.LogObject(SILevel.Verbose, STAppMessages.MSG_SERVICE_REQUEST_REGISTER % SERVICE_SPOTIFY_GET_TRACK_FAVORITES, SERVICE_SPOTIFY_GET_TRACK_FAVORITES_SCHEMA) hass.services.async_register( DOMAIN, @@ -1519,6 +1632,15 @@ def _GetEntityFromServiceData(hass:HomeAssistant, service:ServiceCall, field_id: supports_response=SupportsResponse.ONLY, ) + _logsi.LogObject(SILevel.Verbose, STAppMessages.MSG_SERVICE_REQUEST_REGISTER % SERVICE_SPOTIFY_PLAYER_ACTIVATE_DEVICES, SERVICE_SPOTIFY_PLAYER_ACTIVATE_DEVICES_SCHEMA) + hass.services.async_register( + DOMAIN, + SERVICE_SPOTIFY_PLAYER_ACTIVATE_DEVICES, + service_handle_spotify_serviceresponse, + schema=SERVICE_SPOTIFY_PLAYER_ACTIVATE_DEVICES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + _logsi.LogObject(SILevel.Verbose, STAppMessages.MSG_SERVICE_REQUEST_REGISTER % SERVICE_SPOTIFY_PLAYER_MEDIA_PLAY_CONTEXT, SERVICE_SPOTIFY_PLAYER_MEDIA_PLAY_CONTEXT_SCHEMA) hass.services.async_register( DOMAIN, @@ -1546,6 +1668,15 @@ def _GetEntityFromServiceData(hass:HomeAssistant, service:ServiceCall, field_id: supports_response=SupportsResponse.NONE, ) + _logsi.LogObject(SILevel.Verbose, STAppMessages.MSG_SERVICE_REQUEST_REGISTER % SERVICE_SPOTIFY_PLAYER_RESOLVE_DEVICE_ID, SERVICE_SPOTIFY_PLAYER_RESOLVE_DEVICE_ID_SCHEMA) + hass.services.async_register( + DOMAIN, + SERVICE_SPOTIFY_PLAYER_RESOLVE_DEVICE_ID, + service_handle_spotify_serviceresponse, + schema=SERVICE_SPOTIFY_PLAYER_RESOLVE_DEVICE_ID_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + _logsi.LogObject(SILevel.Verbose, STAppMessages.MSG_SERVICE_REQUEST_REGISTER % SERVICE_SPOTIFY_PLAYER_TRANSFER_PLAYBACK, SERVICE_SPOTIFY_PLAYER_TRANSFER_PLAYBACK_SCHEMA) hass.services.async_register( DOMAIN, @@ -1834,16 +1965,36 @@ async def _update_devices() -> list[dict[str, Any]]: A list of Spotify Device class instances. """ - devices:list[Device] = [] + shouldUpdate:bool = True try: _logsi.LogVerbose("'%s': Component DataUpdateCoordinator is retrieving Spotify device list" % entry.title) - # retrieve list of Spotify Connect devices. - devices:list[Device] = await hass.async_add_executor_job( - spotifyClient.GetPlayerDevices - ) + # get spotify client cached device list. + # if an internal device list cache is present, then use it IF it is less than 5 minutes old; + # otherwise, call GetSpotifyConnectDevices to get the list and update the internal device list cache. + # we check like this since some play commands update the internal device list cache, + # so there is no need to update the device list (resource intensive) if it's not too stale. + scDevices:SpotifyConnectDevices + if "GetSpotifyConnectDevices" in spotifyClient.ConfigurationCache: + scDevices = spotifyClient.ConfigurationCache["GetSpotifyConnectDevices"] + if (scDevices.AgeLastRefreshed > 300): + shouldUpdate = True + else: + _logsi.LogVerbose("'%s': Component DataUpdateCoordinator is using cached device list" % entry.title) + + # do we need to refresh the cache? + if (shouldUpdate): + + # retrieve list of ALL available Spotify Connect devices. + scDevices = await hass.async_add_executor_job( + spotifyClient.GetSpotifyConnectDevices, + True + ) + + # get the device list. + devices:list[Device] = scDevices.GetDeviceList() # trace. _logsi.LogDictionary(SILevel.Verbose, "'%s': Component DataUpdateCoordinator update results" % entry.title, devices, prettyPrint=True) @@ -1940,14 +2091,21 @@ def _TokenUpdater() -> dict: # Continue with async_setup_entry # ----------------------------------------------------------------------------------- - # create new spotify web api python client instance. + # get shared zeroconf instance. + _logsi.LogVerbose("'%s': MediaPlayer async_setup_entry is storing the Zeroconf reference to the instanceData object" % entry.title) + zeroconf_instance = await zeroconf.async_get_instance(hass) + + # create new spotify web api python client instance - "SpotifyClient()". _logsi.LogVerbose("'%s': Component async_setup_entry is creating SpotifyClient instance" % entry.title) tokenStorageDir:str = "%s/custom_components/%s/data" % (hass.config.config_dir, DOMAIN) spotifyClient:SpotifyClient = await hass.async_add_executor_job( SpotifyClient, - None, - tokenStorageDir, - _TokenUpdater + None, # manager:PoolManager=None, + tokenStorageDir, # tokenStorageDir:str=None, + _TokenUpdater, # tokenUpdater:Callable=None, + zeroconf_instance, # zeroconfClient:Zeroconf=None, + entry.options.get(CONF_OPTION_DEVICE_USERNAME, None), + entry.options.get(CONF_OPTION_DEVICE_PASSWORD, None) ) _logsi.LogObject(SILevel.Verbose, "'%s': Component async_setup_entry spotifyClient object" % entry.title, spotifyClient) diff --git a/custom_components/spotifyplus/config_flow.py b/custom_components/spotifyplus/config_flow.py index db657a5..01f102b 100644 --- a/custom_components/spotifyplus/config_flow.py +++ b/custom_components/spotifyplus/config_flow.py @@ -21,7 +21,7 @@ import voluptuous as vol from spotifywebapipython import SpotifyClient -from spotifywebapipython.models import Device +from spotifywebapipython.models import Device, SpotifyConnectDevices from homeassistant.config_entries import ConfigEntry, OptionsFlow from homeassistant.const import CONF_DESCRIPTION, CONF_ID, CONF_NAME, Platform @@ -36,6 +36,8 @@ from .const import ( CONF_OPTION_DEVICE_DEFAULT, + CONF_OPTION_DEVICE_PASSWORD, + CONF_OPTION_DEVICE_USERNAME, CONF_OPTION_SCRIPT_TURN_OFF, CONF_OPTION_SCRIPT_TURN_ON, DOMAIN, @@ -106,11 +108,14 @@ async def async_oauth_create_entry(self, data:dict[str,Any]) -> FlowResult: try: - # create new spotify web api python client instance. + # create new spotify web api python client instance - "SpotifyClient()". _logsi.LogVerbose("Creating SpotifyClient instance") tokenStorageDir:str = "%s/custom_components/%s/data" % (self.hass.config.config_dir, DOMAIN) spotifyClient:SpotifyClient = await self.hass.async_add_executor_job( - SpotifyClient, None, tokenStorageDir, None + SpotifyClient, + None, # manager:PoolManager=None, + tokenStorageDir, # tokenStorageDir:str=None, + None # tokenUpdater:Callable=None, ) _logsi.LogObject(SILevel.Verbose, "SpotifyClient instance created - object", spotifyClient) @@ -353,25 +358,38 @@ async def async_step_init(self, user_input:dict[str,Any]=None) -> FlowResult: # update config entry options from user input values. self._Options[CONF_OPTION_DEVICE_DEFAULT] = user_input.get(CONF_OPTION_DEVICE_DEFAULT, None) + self._Options[CONF_OPTION_DEVICE_USERNAME] = user_input.get(CONF_OPTION_DEVICE_USERNAME, None) + self._Options[CONF_OPTION_DEVICE_PASSWORD] = user_input.get(CONF_OPTION_DEVICE_PASSWORD, None) self._Options[CONF_OPTION_SCRIPT_TURN_OFF] = user_input.get(CONF_OPTION_SCRIPT_TURN_OFF, None) self._Options[CONF_OPTION_SCRIPT_TURN_ON] = user_input.get(CONF_OPTION_SCRIPT_TURN_ON, None) - # store the updated config entry options. - _logsi.LogDictionary(SILevel.Verbose, "'%s': OptionsFlow is updating configuration options - options" % self._name, self._Options) - return self.async_create_entry( - title="", - data=self._Options - ) + # validations. + # if device username was entered then device password is required. + deviceUsername:str = user_input.get(CONF_OPTION_DEVICE_USERNAME, None) + devicePassword:str = user_input.get(CONF_OPTION_DEVICE_PASSWORD, None) + if (deviceUsername is not None) and (devicePassword is None): + errors["base"] = "device_password_required" + + # any validation errors? if not, then ... + if "base" not in errors: + + # store the updated config entry options. + _logsi.LogDictionary(SILevel.Verbose, "'%s': OptionsFlow is updating configuration options - options" % self._name, self._Options) + return self.async_create_entry( + title="", + data=self._Options + ) # load available spotify connect devices. device_list:list[str] = await self.hass.async_add_executor_job(self._GetPlayerDevicesList) - if device_list is None: + if (device_list is None) or (len(device_list) == 0): errors["base"] = "no_player_devices" - return # log device that is currently selected. device_default:str = self._Options.get(CONF_OPTION_DEVICE_DEFAULT, None) _logsi.LogVerbose("'%s': OptionsFlow option '%s' - SELECTED value: '%s'" % (self._name, CONF_OPTION_DEVICE_DEFAULT, device_default)) + device_username:str = self._Options.get(CONF_OPTION_DEVICE_USERNAME, None) + _logsi.LogVerbose("'%s': OptionsFlow option '%s' - SELECTED value: '%s'" % (self._name, CONF_OPTION_DEVICE_USERNAME, device_username)) # create validation schema. schema = vol.Schema( @@ -383,25 +401,33 @@ async def async_step_init(self, user_input:dict[str,Any]=None) -> FlowResult: description={"suggested_value": self._Options.get(CONF_OPTION_DEVICE_DEFAULT)}, ): SelectSelector( SelectSelectorConfig( - options=device_list, + options=device_list or [], mode=SelectSelectorMode.DROPDOWN ) ), + vol.Optional(CONF_OPTION_DEVICE_USERNAME, + description={"suggested_value": self._Options.get(CONF_OPTION_DEVICE_USERNAME)}, + ): cv.string, + vol.Optional(CONF_OPTION_DEVICE_PASSWORD, + description={"suggested_value": self._Options.get(CONF_OPTION_DEVICE_PASSWORD)}, + ): cv.string, vol.Optional(CONF_OPTION_SCRIPT_TURN_ON, description={"suggested_value": self._Options.get(CONF_OPTION_SCRIPT_TURN_ON)}, ): selector.EntitySelector(selector.EntitySelectorConfig(integration=DOMAIN_SCRIPT, - #domain=Platform.SCENE, multiple=False), ), vol.Optional(CONF_OPTION_SCRIPT_TURN_OFF, description={"suggested_value": self._Options.get(CONF_OPTION_SCRIPT_TURN_OFF)}, ): selector.EntitySelector(selector.EntitySelectorConfig(integration=DOMAIN_SCRIPT, - #domain=Platform.SCENE, multiple=False), ), } ) + # any validation errors? if so, then log them. + if "base" in errors: + _logsi.LogDictionary(SILevel.Warning, "'%s': OptionsFlow contained validation errors" % self._name, errors) + _logsi.LogVerbose("'%s': OptionsFlow is showing the init configuration options form" % self._name) return self.async_show_form( step_id="init", @@ -438,15 +464,12 @@ def _GetPlayerDevicesList(self) -> list: # get spotify connect player device list. _logsi.LogVerbose("'%s': OptionsFlow is retrieving Spotify Connect player devices" % self._name) - devices:list[Device] = data.spotifyClient.GetPlayerDevices(refresh=True) - - # sort the results (in place) by Name, ascending order. - devices.sort(key=lambda x: (x.Name or "").lower(), reverse=False) - + devices:SpotifyConnectDevices = data.spotifyClient.GetSpotifyConnectDevices() + # build string array of all devices. result:list = [] item:Device - for item in devices: + for item in devices.GetDeviceList(): result.append(item.SelectItemNameAndId) # trace. diff --git a/custom_components/spotifyplus/const.py b/custom_components/spotifyplus/const.py index 690b081..6b5971e 100644 --- a/custom_components/spotifyplus/const.py +++ b/custom_components/spotifyplus/const.py @@ -13,6 +13,8 @@ LOGGER = logging.getLogger(__package__) CONF_OPTION_DEVICE_DEFAULT = "device_default" +CONF_OPTION_DEVICE_PASSWORD = "device_password" +CONF_OPTION_DEVICE_USERNAME = "device_username" CONF_OPTION_SCRIPT_TURN_ON = "script_turn_on" CONF_OPTION_SCRIPT_TURN_OFF = "script_turn_off" diff --git a/custom_components/spotifyplus/instancedata_spotifyplus.py b/custom_components/spotifyplus/instancedata_spotifyplus.py index 0f012cd..150215c 100644 --- a/custom_components/spotifyplus/instancedata_spotifyplus.py +++ b/custom_components/spotifyplus/instancedata_spotifyplus.py @@ -13,6 +13,8 @@ from .const import ( CONF_OPTION_DEVICE_DEFAULT, + CONF_OPTION_DEVICE_PASSWORD, + CONF_OPTION_DEVICE_USERNAME, CONF_OPTION_SCRIPT_TURN_OFF, CONF_OPTION_SCRIPT_TURN_ON, ) @@ -29,7 +31,7 @@ class InstanceDataSpotifyPlus: devices: DataUpdateCoordinator[list[Device]] """ - List of Spotify Connect devices that are available for this Spotify user. + List of Spotify Connect devices that are available. This property is refreshed every 5 minutes by a DataUpdateCoordinator. """ @@ -52,7 +54,7 @@ class InstanceDataSpotifyPlus: """ The SpotifyClient instance used to interface with the Spotify Web API. """ - + @property def OptionDeviceDefault(self) -> str | None: @@ -61,6 +63,20 @@ def OptionDeviceDefault(self) -> str | None: """ return self.options.get(CONF_OPTION_DEVICE_DEFAULT, None) + @property + def OptionDevicePassword(self) -> str | None: + """ + The default Spotify Connect password to use when connecting to an inactive device. + """ + return self.options.get(CONF_OPTION_DEVICE_PASSWORD, None) + + @property + def OptionDeviceUsername(self) -> str | None: + """ + The default Spotify Connect username to use when connecting to an inactive device. + """ + return self.options.get(CONF_OPTION_DEVICE_USERNAME, None) + @property def OptionScriptTurnOff(self) -> str | None: """ diff --git a/custom_components/spotifyplus/manifest.json b/custom_components/spotifyplus/manifest.json index b3e04c4..658bd21 100644 --- a/custom_components/spotifyplus/manifest.json +++ b/custom_components/spotifyplus/manifest.json @@ -3,8 +3,11 @@ "name": "SpotifyPlus", "codeowners": [ "@thlucas1" ], "config_flow": true, - "dependencies": [ "application_credentials" ], - "documentation": "https://github.com/thlucas1/homeassistantcomponent_spotifyplus/wiki", + "dependencies": [ + "application_credentials", + "zeroconf" + ], + "documentation": "https://github.com/thlucas1/homeassistantcomponent_spotifyplus/wiki/Device-Configuration-Options#integration-options", "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/thlucas1/homeassistantcomponent_spotifyplus/issues", @@ -14,10 +17,10 @@ "requests>=2.31.0", "requests_oauthlib>=1.3.1", "smartinspectPython>=3.0.33", - "spotifywebapiPython>=1.0.48", + "spotifywebapiPython>=1.0.59", "urllib3>=1.21.1,<1.27", "zeroconf>=0.132.2" ], - "version": "1.0.27", + "version": "1.0.28", "zeroconf": [ "_spotify-connect._tcp.local." ] } diff --git a/custom_components/spotifyplus/media_player.py b/custom_components/spotifyplus/media_player.py index ee8b1da..f644beb 100644 --- a/custom_components/spotifyplus/media_player.py +++ b/custom_components/spotifyplus/media_player.py @@ -37,6 +37,7 @@ SearchResponse, Show, ShowPageSaved, + SpotifyConnectDevices, Track, TrackPage, TrackPageSaved, @@ -55,7 +56,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, IntegrationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -173,7 +174,8 @@ async def async_setup_entry(hass:HomeAssistant, entry:ConfigEntry, async_add_ent # store the reference to the media player object. _logsi.LogVerbose("'%s': MediaPlayer async_setup_entry is storing the SpotifyMediaPlayer reference to hass.data[DOMAIN]" % entry.title) hass.data[DOMAIN][entry.entry_id].media_player = media_player - + + # trace. _logsi.LogVerbose("'%s': MediaPlayer async_setup_entry complete" % entry.title) except Exception as ex: @@ -216,6 +218,8 @@ def wrapper(self: _SpotifyMediaPlayerT, *args: _P.args, **kwargs: _P.kwargs) -> # return function result to caller. return result + except HomeAssistantError: raise # pass handled exceptions on thru + except ValueError: raise # pass handled exceptions on thru except SpotifyApiError as ex: raise HomeAssistantError(ex.Message) except SpotifyWebApiError as ex: @@ -256,7 +260,7 @@ def __init__(self, data:InstanceDataSpotifyPlus) -> None: # trace. methodParms = _logsi.EnterMethodParmList(SILevel.Debug) - methodParms.AppendKeyValue("data.devices", str(data.devices)) + #methodParms.AppendKeyValue("data.devices", str(data.devices)) methodParms.AppendKeyValue("data.media_player", str(data.media_player)) methodParms.AppendKeyValue("data.session", str(data.session)) methodParms.AppendKeyValue("data.spotifyClient", str(data.spotifyClient)) @@ -325,7 +329,15 @@ def __init__(self, data:InstanceDataSpotifyPlus) -> None: | MediaPlayerEntityFeature.VOLUME_STEP else: _logsi.LogVerbose("'%s': MediaPlayer is setting supported features for Spotify Non-Premium user" % self.name) - self._attr_supported_features = MediaPlayerEntityFeature.BROWSE_MEDIA + self._attr_supported_features = MediaPlayerEntityFeature.BROWSE_MEDIA \ + | MediaPlayerEntityFeature.PLAY \ + | MediaPlayerEntityFeature.PLAY_MEDIA \ + | MediaPlayerEntityFeature.SELECT_SOURCE \ + | MediaPlayerEntityFeature.TURN_OFF \ + | MediaPlayerEntityFeature.TURN_ON \ + | MediaPlayerEntityFeature.VOLUME_MUTE \ + | MediaPlayerEntityFeature.VOLUME_SET \ + | MediaPlayerEntityFeature.VOLUME_STEP # we will (by default) set polling to true, as the SpotifyClient does not support websockets # for player update notifications. @@ -349,7 +361,7 @@ def __init__(self, data:InstanceDataSpotifyPlus) -> None: @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict: """ Return entity specific state attributes. """ # build list of our extra state attributes to return to HA UI. attributes = {} @@ -361,11 +373,13 @@ def extra_state_attributes(self): if self._playerState.Device is not None: attributes[ATTR_SPOTIFYPLUS_DEVICE_ID] = self._playerState.Device.Id attributes[ATTR_SPOTIFYPLUS_DEVICE_NAME] = self._playerState.Device.Name + if self._playerState.Context is not None: + attributes['media_context_content_id'] = self._playerState.Context.Uri # add currently active playlist information. if self._playlist is not None: attributes['media_playlist_content_id'] = self._playlist.Uri - + return attributes @@ -447,7 +461,6 @@ def media_playlist(self): return None - @property @property def media_playlist_content_id(self): """ Content ID of current playing playlist. """ @@ -464,6 +477,7 @@ def media_playlist_content_type(self): return None + @property def media_playlist_description(self): """ Description of current playing playlist. """ if self._playlist is not None: @@ -488,10 +502,18 @@ def source(self) -> str | None: @property def source_list(self) -> list[str] | None: """ Return a list of source devices. """ - deviceNames:list[str] = [] - for device in self.data.devices.data: - deviceNames.append(device.Name) - return deviceNames + + # get spotify client cached device list. + if "GetSpotifyConnectDevices" in self.data.spotifyClient.ConfigurationCache: + result:SpotifyConnectDevices = self.data.spotifyClient.ConfigurationCache["GetSpotifyConnectDevices"] + + # build list of device names for the source list. + deviceNames:list[str] = [] + for device in result.GetDeviceList(): + deviceNames.append(device.Name) + return deviceNames + + return None @property @@ -703,11 +725,23 @@ def select_source(self, source: str) -> None: """ Select playback device. """ _logsi.LogVerbose(STAppMessages.MSG_MEDIAPLAYER_SERVICE_WITH_PARMS, self.name, "select_source", "source='%s'" % (source)) - # search device list for matching device name. - for device in self.data.devices.data: - if device.Name == source: - self.data.spotifyClient.PlayerTransferPlayback(device.Id, (self.state == MediaPlayerState.PLAYING)) - return + # are we currently powered off? if so, then power on. + wasTurnedOn:bool = False + if self._attr_state == MediaPlayerState.OFF: + self.turn_on() + wasTurnedOn = True + # immediately pause (if not already) to ensure media starts playing on the correct device after transfer. + self.media_pause() + self._isInCommandEvent = True # turn "in a command event" indicator back on. + + if source is not None: + + # transfer playback to the specified device. + self.data.spotifyClient.PlayerTransferPlayback(source, (self.state == MediaPlayerState.PLAYING)) + + # if player was turned on, then resume play on the new device. + if wasTurnedOn: + self.media_play() @spotify_exception_handler @@ -784,7 +818,7 @@ def turn_off(self) -> None: _logsi.LogObject(SILevel.Verbose, "'%s': Spotify player state at power off" % self.name, self._playerState, excludeNonPublic=True) # if playing, then pause playback. - if self._playerState.IsPlaying: + if (self._playerState.IsPlaying) and (self.data.spotifyClient.UserProfile.Product == 'premium'): _logsi.LogVerbose("'%s': Pausing Spotify playback on deviceId: %s" % (self.name, self._playerState.Device.Id)) self.data.spotifyClient.PlayerMediaPause(self._playerState.Device.Id) @@ -2084,6 +2118,141 @@ def service_spotify_get_player_devices(self, _logsi.LeaveMethod(SILevel.Debug, apiMethodName) + def service_spotify_get_player_now_playing( + self, + market:str=None, + additionalTypes:str=None + ) -> dict: + """ + Get the object currently being played on the user's Spotify account. + + This method requires the `user-read-currently-playing` scope. + + Args: + market (str): + An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that + is available in that market will be returned. If a valid user access token is specified + in the request header, the country associated with the user account will take priority over + this parameter. + Note: If neither market or user country are provided, the content is considered unavailable for the client. + Users can view the country that is associated with their account in the account settings. + Example: `ES` + additionalTypes (str): + A comma-separated list of item types that your client supports besides the default track type. + Valid types are: `track` and `episode`. + Specify `episode` to get podcast track information. + Note: This parameter was introduced to allow existing clients to maintain their current behaviour + and might be deprecated in the future. In addition to providing this parameter, make sure that your client + properly handles cases of new types in the future by checking against the type field of each object. + + Returns: + A dictionary that contains the following keys: + - user_profile: A (partial) user profile that retrieved the result. + - result: A `PlayerPlayState` object that contains the player now playing details. + """ + apiMethodName:str = 'service_spotify_get_player_now_playing' + apiMethodParms:SIMethodParmListContext = None + result:PlayerPlayState = None + + try: + + # trace. + apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName) + apiMethodParms.AppendKeyValue("market", market) + apiMethodParms.AppendKeyValue("additionalTypes", additionalTypes) + _logsi.LogMethodParmList(SILevel.Verbose, "Spotify Get Player Now Playing Service", apiMethodParms) + + # request information from Spotify Web API. + _logsi.LogVerbose(STAppMessages.MSG_SERVICE_QUERY_WEB_API) + result = self.data.spotifyClient.GetPlayerNowPlaying(market, additionalTypes) + + # return the (partial) user profile that retrieved the result, as well as the result itself. + return { + "user_profile": self._GetUserProfilePartialDictionary(self.data.spotifyClient.UserProfile), + "result": result.ToDictionary() + } + + # the following exceptions have already been logged, so we just need to + # pass them back to HA for display in the log (or service UI). + except SpotifyApiError as ex: + raise HomeAssistantError(ex.Message) + except SpotifyWebApiError as ex: + raise HomeAssistantError(ex.Message) + + finally: + + # trace. + _logsi.LeaveMethod(SILevel.Debug, apiMethodName) + + + def service_spotify_get_player_playback_state( + self, + market:str=None, + additionalTypes:str=None + ) -> dict: + """ + Get information about the user's current playback state, including track or episode, progress, + and active device. + + This method requires the `user-read-playback-state` scope. + + Args: + market (str): + An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that + is available in that market will be returned. If a valid user access token is specified + in the request header, the country associated with the user account will take priority over + this parameter. + Note: If neither market or user country are provided, the content is considered unavailable for the client. + Users can view the country that is associated with their account in the account settings. + Example: `ES` + additionalTypes (str): + A comma-separated list of item types that your client supports besides the default track type. + Valid types are: `track` and `episode`. + Specify `episode` to get podcast track information. + Note: This parameter was introduced to allow existing clients to maintain their current behaviour + and might be deprecated in the future. In addition to providing this parameter, make sure that your client + properly handles cases of new types in the future by checking against the type field of each object. + + Returns: + A dictionary that contains the following keys: + - user_profile: A (partial) user profile that retrieved the result. + - result: A `PlayerPlayState` object that contains the playback state details. + """ + apiMethodName:str = 'service_spotify_get_player_playback_state' + apiMethodParms:SIMethodParmListContext = None + result:PlayerPlayState = None + + try: + + # trace. + apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName) + apiMethodParms.AppendKeyValue("market", market) + apiMethodParms.AppendKeyValue("additionalTypes", additionalTypes) + _logsi.LogMethodParmList(SILevel.Verbose, "Spotify Get Player Playback State Service", apiMethodParms) + + # request information from Spotify Web API. + _logsi.LogVerbose(STAppMessages.MSG_SERVICE_QUERY_WEB_API) + result = self.data.spotifyClient.GetPlayerPlaybackState(market, additionalTypes) + + # return the (partial) user profile that retrieved the result, as well as the result itself. + return { + "user_profile": self._GetUserProfilePartialDictionary(self.data.spotifyClient.UserProfile), + "result": result.ToDictionary() + } + + # the following exceptions have already been logged, so we just need to + # pass them back to HA for display in the log (or service UI). + except SpotifyApiError as ex: + raise HomeAssistantError(ex.Message) + except SpotifyWebApiError as ex: + raise HomeAssistantError(ex.Message) + + finally: + + # trace. + _logsi.LeaveMethod(SILevel.Debug, apiMethodName) + + def service_spotify_get_player_queue_info(self) -> dict: """ Get the list of objects that make up the user's playback queue. @@ -2538,6 +2707,58 @@ def service_spotify_get_show_favorites(self, _logsi.LeaveMethod(SILevel.Debug, apiMethodName) + def service_spotify_get_spotify_connect_devices( + self, + ) -> dict: + """ + Get information about all available Spotify Connect player devices. + + This method requires the `user-read-playback-state` scope. + + Returns: + A dictionary that contains the following keys: + - user_profile: A (partial) user profile that retrieved the result. + - result: A list of `Device` objects that contain the device details, sorted by name. + """ + apiMethodName:str = 'service_spotify_get_spotify_connect_devices' + apiMethodParms:SIMethodParmListContext = None + result:SpotifyConnectDevices = None + + try: + + # trace. + apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName) + _logsi.LogMethodParmList(SILevel.Verbose, "Spotify Get Spotify Connect Devices Service", apiMethodParms) + + # request information from Spotify Web API. + _logsi.LogVerbose(STAppMessages.MSG_SERVICE_QUERY_WEB_API) + result = self.data.spotifyClient.GetSpotifyConnectDevices() + + # build dictionary result from array. + resultArray:list = [] + item:PlayerDevice + for item in result.GetDeviceList(): + resultArray.append(item.ToDictionary()) + + # return the (partial) user profile that retrieved the result, as well as the result itself. + return { + "user_profile": self._GetUserProfilePartialDictionary(self.data.spotifyClient.UserProfile), + "result": resultArray + } + + # the following exceptions have already been logged, so we just need to + # pass them back to HA for display in the log (or service UI). + except SpotifyApiError as ex: + raise HomeAssistantError(ex.Message) + except SpotifyWebApiError as ex: + raise HomeAssistantError(ex.Message) + + finally: + + # trace. + _logsi.LeaveMethod(SILevel.Debug, apiMethodName) + + def service_spotify_get_track_favorites(self, limit:int, offset:int, @@ -2756,6 +2977,63 @@ def service_spotify_get_users_top_tracks(self, _logsi.LeaveMethod(SILevel.Debug, apiMethodName) + def service_spotify_player_activate_devices( + self, + verifyUserContext:bool=False, + delay:float=0.50, + ) -> dict: + """ + Activates all Spotify Connect player devices, and (optionally) switches the active user + context to the current user context for each device. + + Args: + verifyUserContext (bool): + If True, the active user context of the resolved device is checked to ensure it + matches the user context specified on the class constructor. + If False, the user context will not be checked. + Default is False. + delay (float): + Time delay (in seconds) to wait AFTER issuing the final Connect command (if necessary). + This delay will give the spotify web api time to process the device list change before + another command is issued. + Default is 0.50; value range is 0 - 10. + """ + apiMethodName:str = 'service_spotify_player_activate_devices' + apiMethodParms:SIMethodParmListContext = None + + try: + + # trace. + apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName) + apiMethodParms.AppendKeyValue("verifyUserContext", verifyUserContext) + apiMethodParms.AppendKeyValue("delay", delay) + _logsi.LogMethodParmList(SILevel.Verbose, "Spotify Player Activate Devices Service", apiMethodParms) + + # process Spotify Web API request. + _logsi.LogVerbose(STAppMessages.MSG_SERVICE_QUERY_WEB_API) + result = self.data.spotifyClient.PlayerActivateDevices(verifyUserContext, delay) + + # return the (partial) user profile that retrieved the result, as well as the result itself. + return { + "user_profile": self._GetUserProfilePartialDictionary(self.data.spotifyClient.UserProfile), + "result": result + } + + # the following exceptions have already been logged, so we just need to + # pass them back to HA for display in the log (or service UI). + except SpotifyApiError as ex: + raise HomeAssistantError(ex.Message) + except SpotifyWebApiError as ex: + raise HomeAssistantError(ex.Message) + except SpotifyZeroconfApiError as ex: + raise HomeAssistantError(ex.Message) + + finally: + + # trace. + _logsi.LeaveMethod(SILevel.Debug, apiMethodName) + + @spotify_exception_handler def service_spotify_player_media_play_context(self, contextUri:str, @@ -2816,11 +3094,6 @@ def service_spotify_player_media_play_context(self, _logsi.LogVerbose("Playing Media Context on device") self.data.spotifyClient.PlayerMediaPlayContext(contextUri, offsetUri, offsetPosition, positionMS, deviceId) - # # issue transfer playback in case it needs it. - # if deviceId is not None: - # _logsi.LogVerbose("Transferring Spotify Playback to device") - # self.data.spotifyClient.PlayerTransferPlayback(deviceId, True) - # update ha state. self.schedule_update_ha_state(force_refresh=False) @@ -2828,6 +3101,7 @@ def service_spotify_player_media_play_context(self, # pass them back to HA for display in the log (or service UI). except SpotifyApiError as ex: raise HomeAssistantError(ex.Message) + except SpotifyWebApiError as ex: raise HomeAssistantError(ex.Message) @@ -2878,11 +3152,6 @@ def service_spotify_player_media_play_track_favorites(self, _logsi.LogVerbose("Playing Media Favorite Tracks on device") self.data.spotifyClient.PlayerMediaPlayTrackFavorites(deviceId, shuffle, delay) - # # issue transfer playback in case it needs it. - # if deviceId is not None: - # _logsi.LogVerbose("Transferring Spotify Playback to device") - # self.data.spotifyClient.PlayerTransferPlayback(deviceId, True) - # update ha state. self.schedule_update_ha_state(force_refresh=False) @@ -2945,11 +3214,6 @@ def service_spotify_player_media_play_tracks(self, _logsi.LogVerbose("Playing Media Tracks on device") self.data.spotifyClient.PlayerMediaPlayTracks(uris, positionMS, deviceId) - # # issue transfer playback in case it needs it. - # if deviceId is not None: - # _logsi.LogVerbose("Transferring Spotify Playback to device") - # self.data.spotifyClient.PlayerTransferPlayback(deviceId, True) - # update ha state. self.schedule_update_ha_state(force_refresh=False) @@ -2966,6 +3230,68 @@ def service_spotify_player_media_play_tracks(self, _logsi.LeaveMethod(SILevel.Debug, apiMethodName) + def service_spotify_player_resolve_device_id( + self, + deviceValue:str, + verifyUserContext:bool=True, + verifyTimeout:float=5.0, + ) -> dict: + """ + Resolves a Spotify Connect device identifier from a specified device id, name, alias id, + or alias name. This will ensure that the device id can be found on the network, as well + as connect to the device if necessary with the current user context. + + Args: + deviceValue (str): + The device id / name value to check. + verifyUserContext (bool): + If True, the active user context of the resolved device is checked to ensure it + matches the user context specified on the class constructor. + If False, the user context will not be checked. + Default is True. + verifyTimeout (float): + Maximum time to wait (in seconds) for the device to become active in the Spotify + Connect device list. This value is only used if a Connect command has to be + issued to activate the device. + Default is 5; value range is 0 - 10. + """ + apiMethodName:str = 'service_spotify_player_resolve_device_id' + apiMethodParms:SIMethodParmListContext = None + + try: + + # trace. + apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName) + apiMethodParms.AppendKeyValue("deviceValue", deviceValue) + apiMethodParms.AppendKeyValue("verifyUserContext", verifyUserContext) + apiMethodParms.AppendKeyValue("verifyTimeout", verifyTimeout) + _logsi.LogMethodParmList(SILevel.Verbose, "Spotify Player Resolve Device Id Service", apiMethodParms) + + # process Spotify Web API request. + _logsi.LogVerbose(STAppMessages.MSG_SERVICE_QUERY_WEB_API) + result = self.data.spotifyClient.PlayerResolveDeviceId(deviceValue, verifyUserContext, verifyTimeout) + + # return the (partial) user profile that retrieved the result, as well as the result itself. + return { + "user_profile": self._GetUserProfilePartialDictionary(self.data.spotifyClient.UserProfile), + "result": result + } + + # the following exceptions have already been logged, so we just need to + # pass them back to HA for display in the log (or service UI). + except SpotifyApiError as ex: + raise HomeAssistantError(ex.Message) + except SpotifyWebApiError as ex: + raise HomeAssistantError(ex.Message) + except SpotifyZeroconfApiError as ex: + raise HomeAssistantError(ex.Message) + + finally: + + # trace. + _logsi.LeaveMethod(SILevel.Debug, apiMethodName) + + def service_spotify_player_transfer_playback(self, deviceId:str, play:bool=True, @@ -4644,7 +4970,7 @@ def service_spotify_zeroconf_discover_devices(self, # create a new instance of the discovery class. # do not verify device connections; # do not print device details to the console as they are discovered. - discovery:SpotifyDiscovery = SpotifyDiscovery(self.data.spotifyClient, False, printToConsole=False) + discovery:SpotifyDiscovery = SpotifyDiscovery(self.data.spotifyClient.ZeroconfClient, printToConsole=False) # discover Spotify Connect devices on the network, waiting up to the specified # time in seconds for all devices to be discovered. @@ -4693,6 +5019,10 @@ async def async_added_to_hass(self) -> None: # call base class method. await super().async_added_to_hass() + # load list of supported sources (also caches them in the client for later). + _logsi.LogVerbose("'%s': MediaPlayer is loading list of ALL sources that the device supports" % self.name) + result:SpotifyConnectDevices = await self.hass.async_add_executor_job(self.data.spotifyClient.GetSpotifyConnectDevices, True) + # add listener that will inform HA of our state if a user removes the device instance. _logsi.LogVerbose("'%s': adding '_handle_devices_update' listener" % self.name) self.async_on_remove( diff --git a/custom_components/spotifyplus/services.yaml b/custom_components/spotifyplus/services.yaml index 32ba083..34c6f62 100644 --- a/custom_components/spotifyplus/services.yaml +++ b/custom_components/spotifyplus/services.yaml @@ -493,6 +493,62 @@ get_player_devices: selector: boolean: +get_player_now_playing: + name: Get Player Now Playing + description: Get the object currently being played on the user's Spotify account. + fields: + entity_id: + name: Entity ID + description: Entity ID of the SpotifyPlus device that will make the request to the Spotify Web API. + example: "media_player.spotifyplus_username" + required: true + selector: + entity: + integration: spotifyplus + domain: media_player + market: + name: Market + description: An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. The country associated with the user account will take priority over this parameter. + example: "ES" + required: false + selector: + text: + additional_types: + name: Additional Types + description: A comma-separated list of item types that your client supports besides the default track type. Valid types are 'track' and 'episode'. + example: "episode" + required: false + selector: + text: + +get_player_playback_state: + name: Get Player Playback State + description: Get information about the user's current playback state, including track or episode, progress, and active device. + fields: + entity_id: + name: Entity ID + description: Entity ID of the SpotifyPlus device that will make the request to the Spotify Web API. + example: "media_player.spotifyplus_username" + required: true + selector: + entity: + integration: spotifyplus + domain: media_player + market: + name: Market + description: An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. The country associated with the user account will take priority over this parameter. + example: "ES" + required: false + selector: + text: + additional_types: + name: Additional Types + description: A comma-separated list of item types that your client supports besides the default track type. Valid types are 'track' and 'episode'. + example: "episode" + required: false + selector: + text: + get_player_queue_info: name: Get Player Queue Info description: Get the list of objects that make up the user's playback queue. @@ -770,6 +826,20 @@ get_show_favorites: number: mode: box +get_spotify_connect_devices: + name: Get Spotify Connect Devices + description: Get information about all available Spotify Connect player (both static and dynamic) devices. + fields: + entity_id: + name: Entity ID + description: Entity ID of the SpotifyPlus device that will make the request to the Spotify Web API. + example: "media_player.spotifyplus_username" + required: true + selector: + entity: + integration: spotifyplus + domain: media_player + get_track_favorites: name: Get Track Favorites description: Get a list of the tracks saved in the current Spotify user's 'Your Library'. @@ -917,6 +987,34 @@ get_users_top_tracks: number: mode: box +player_activate_devices: + name: Player Activate Devices + description: Activates all static Spotify Connect player devices, and (optionally) switches the active user context to the current user context. + fields: + entity_id: + name: Entity ID + description: Entity ID of the SpotifyPlus device that will make the request to the Spotify Web API. + example: "media_player.spotifyplus_username" + required: true + selector: + entity: + integration: spotifyplus + domain: media_player + verify_user_context: + name: Verify User Context? + description: If True, the active user context of the resolved device is checked to ensure it matches the specified Spotify Connect user context. If False, the user context will not be checked. Default is False. + example: "True" + required: false + selector: + boolean: + delay: + name: Delay + description: Time delay (in seconds) to wait AFTER issuing the final Connect command (if necessary). This delay will give the spotify web api time to process the device list change before another command is issued. Default is 0.50; value range is 0 - 10. + example: "0.50" + required: false + selector: + text: + player_media_play_context: name: Player Media Play Context description: Start playing one or more tracks of the specified context on a Spotify Connect device. @@ -1045,6 +1143,41 @@ player_media_play_tracks: selector: text: +player_resolve_device_id: + name: Player Resolve Device ID + description: Resolves a Spotify Connect device identifier from a specified device id, name, alias id, or alias name. This will ensure that the device id can be found on the network, as well as connect to the device if necessary with the current user context. + fields: + entity_id: + name: Entity ID + description: Entity ID of the SpotifyPlus device that will make the request to the Spotify Web API. + example: "media_player.spotifyplus_username" + required: true + selector: + entity: + integration: spotifyplus + domain: media_player + device_value: + name: Device Value + description: The device id (e.g. '0d1841b0976bae2a3a310dd74c0f337465899bc8') or name (e.g. 'Bose-ST10-1') value to resolve. + example: "Bose-ST10-1" + required: true + selector: + text: + verify_user_context: + name: Verify User Context? + description: If True, the active user context of the resolved device is checked to ensure it matches the user context specified on the class constructor. If False, the user context will not be checked. Default is True. + example: "True" + required: false + selector: + boolean: + verify_timeout: + name: Verify Timeout + description: Time delay (in seconds) to wait AFTER issuing the final Connect command (if necessary). This delay will give the spotify web api time to process the device list change before another command is issued. Default is 5.0; value range is 0 - 10. + example: "5.0" + required: false + selector: + text: + player_transfer_playback: name: Player Transfer Playback description: Transfer playback to a new Spotify Connect device and optionally begin playback. diff --git a/custom_components/spotifyplus/strings.json b/custom_components/spotifyplus/strings.json index 8ada419..170e4f0 100644 --- a/custom_components/spotifyplus/strings.json +++ b/custom_components/spotifyplus/strings.json @@ -24,6 +24,8 @@ "description": "Configure SpotifyPlus integration options that control functionality.", "data": { "device_default": "Default Spotify Connect Player Device ID when none are active.", + "device_password": "Default Spotify Connect password to use when connecting to an inactive device.", + "device_username": "Default Spotify Connect username to use when connecting to an inactive device.", "script_turn_on": "Script called to turn on device that plays media content.", "script_turn_off": "Script called to turn off device that plays media content." }, @@ -31,7 +33,8 @@ } }, "error": { - "no_player_devices": "Per Spotify Web API, there are currently no Spotify Connect devices active. Please close the configuration options, play a track on any Spotify Connect player for a minute or two, and then open the configuration options again." + "no_player_devices": "Per Spotify Web API, there are currently no Spotify Connect devices active. Please close the configuration options, play a track on any Spotify Connect player for a minute or two, and then open the configuration options again.", + "device_password_required": "Default Device Password is required if a Default Device Username was specified." } }, "system_health": { @@ -334,6 +337,42 @@ } } }, + "get_player_now_playing": { + "name": "Get Player Now Playing", + "description": "Get the object currently being played on the user's Spotify account.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Entity ID of the SpotifyPlus device that will make the request to the Spotify Web API." + }, + "market": { + "name": "Market / Country Code", + "description": "An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. The country associated with the user account will take priority over this parameter." + }, + "additional_types": { + "name": "Additional Types", + "description": "A comma-separated list of item types that your client supports besides the default track type. Valid types are 'track' and 'episode'." + } + } + }, + "get_player_playback_state": { + "name": "Get Player Playback State", + "description": "Get information about the user's current playback state, including track or episode, progress, and active device.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Entity ID of the SpotifyPlus device that will make the request to the Spotify Web API." + }, + "market": { + "name": "Market / Country Code", + "description": "An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. The country associated with the user account will take priority over this parameter." + }, + "additional_types": { + "name": "Additional Types", + "description": "A comma-separated list of item types that your client supports besides the default track type. Valid types are 'track' and 'episode'." + } + } + }, "get_player_queue_info": { "name": "Get Player Queue Info", "description": "Get the list of objects that make up the user's playback queue.", @@ -488,6 +527,16 @@ } } }, + "get_spotify_connect_devices": { + "name": "Get Spotify Connect Devices", + "description": "Get information about all available Spotify Connect player (both static and dynamic) devices.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Entity ID of the SpotifyPlus device that will make the request to the Spotify Web API." + } + } + }, "get_track_favorites": { "name": "Get Track Favorites", "description": "Get a list of the tracks saved in the current Spotify user's 'Your Library'.", @@ -566,6 +615,24 @@ } } }, + "player_activate_devices": { + "name": "Player Activate Devices", + "description": "Activates all Spotify Connect player devices, and (optionally) switches the active user context to the current user context.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Entity ID of the SpotifyPlus device that will make the request to the Spotify Web API." + }, + "verify_user_context": { + "name": "Verify User Context?", + "description": "If True, the active user context of the resolved device is checked to ensure it matches the specified Spotify Connect user context. If False, the user context will not be checked. Default is False." + }, + "delay": { + "name": "Delay", + "description": "Time delay (in seconds) to wait AFTER issuing the final Connect command (if necessary). This delay will give the spotify web api time to process the device list change before another command is issued. Default is 0.50; value range is 0 - 10." + } + } + }, "player_media_play_context": { "name": "Player Media Play Context", "description": "Start playing one or more tracks of the specified context on a Spotify Connect device.", @@ -640,6 +707,28 @@ } } }, + "player_resolve_device_id": { + "name": "Player Resolve Device ID", + "description": "Resolves a Spotify Connect device identifier from a specified device id, name, alias id, or alias name. This will ensure that the device id can be found on the network, as well as connect to the device if necessary with the current user context. ", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Entity ID of the SpotifyPlus device that will make the request to the Spotify Web API." + }, + "device_value": { + "name": "Device Value", + "description": "The device id (e.g. '0d1841b0976bae2a3a310dd74c0f337465899bc8') or name (e.g. 'Bose-ST10-1') value to resolve." + }, + "verify_user_context": { + "name": "Verify User Context?", + "description": "If True, the active user context of the resolved device is checked to ensure it matches the user context specified on the class constructor. If False, the user context will not be checked. Default is True." + }, + "verify_timeout": { + "name": "Verify Timeout", + "description": "Time delay (in seconds) to wait AFTER issuing the final Connect command (if necessary). This delay will give the spotify web api time to process the device list change before another command is issued. Default is 5.0; value range is 0 - 10." + } + } + }, "player_transfer_playback": { "name": "Player Transfer Playback", "description": "Transfer playback to a new Spotify Connect device and optionally begin playback.", diff --git a/custom_components/spotifyplus/translations/en.json b/custom_components/spotifyplus/translations/en.json index 8ada419..170e4f0 100644 --- a/custom_components/spotifyplus/translations/en.json +++ b/custom_components/spotifyplus/translations/en.json @@ -24,6 +24,8 @@ "description": "Configure SpotifyPlus integration options that control functionality.", "data": { "device_default": "Default Spotify Connect Player Device ID when none are active.", + "device_password": "Default Spotify Connect password to use when connecting to an inactive device.", + "device_username": "Default Spotify Connect username to use when connecting to an inactive device.", "script_turn_on": "Script called to turn on device that plays media content.", "script_turn_off": "Script called to turn off device that plays media content." }, @@ -31,7 +33,8 @@ } }, "error": { - "no_player_devices": "Per Spotify Web API, there are currently no Spotify Connect devices active. Please close the configuration options, play a track on any Spotify Connect player for a minute or two, and then open the configuration options again." + "no_player_devices": "Per Spotify Web API, there are currently no Spotify Connect devices active. Please close the configuration options, play a track on any Spotify Connect player for a minute or two, and then open the configuration options again.", + "device_password_required": "Default Device Password is required if a Default Device Username was specified." } }, "system_health": { @@ -334,6 +337,42 @@ } } }, + "get_player_now_playing": { + "name": "Get Player Now Playing", + "description": "Get the object currently being played on the user's Spotify account.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Entity ID of the SpotifyPlus device that will make the request to the Spotify Web API." + }, + "market": { + "name": "Market / Country Code", + "description": "An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. The country associated with the user account will take priority over this parameter." + }, + "additional_types": { + "name": "Additional Types", + "description": "A comma-separated list of item types that your client supports besides the default track type. Valid types are 'track' and 'episode'." + } + } + }, + "get_player_playback_state": { + "name": "Get Player Playback State", + "description": "Get information about the user's current playback state, including track or episode, progress, and active device.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Entity ID of the SpotifyPlus device that will make the request to the Spotify Web API." + }, + "market": { + "name": "Market / Country Code", + "description": "An ISO 3166-1 alpha-2 country code. If a country code is specified, only content that is available in that market will be returned. The country associated with the user account will take priority over this parameter." + }, + "additional_types": { + "name": "Additional Types", + "description": "A comma-separated list of item types that your client supports besides the default track type. Valid types are 'track' and 'episode'." + } + } + }, "get_player_queue_info": { "name": "Get Player Queue Info", "description": "Get the list of objects that make up the user's playback queue.", @@ -488,6 +527,16 @@ } } }, + "get_spotify_connect_devices": { + "name": "Get Spotify Connect Devices", + "description": "Get information about all available Spotify Connect player (both static and dynamic) devices.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Entity ID of the SpotifyPlus device that will make the request to the Spotify Web API." + } + } + }, "get_track_favorites": { "name": "Get Track Favorites", "description": "Get a list of the tracks saved in the current Spotify user's 'Your Library'.", @@ -566,6 +615,24 @@ } } }, + "player_activate_devices": { + "name": "Player Activate Devices", + "description": "Activates all Spotify Connect player devices, and (optionally) switches the active user context to the current user context.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Entity ID of the SpotifyPlus device that will make the request to the Spotify Web API." + }, + "verify_user_context": { + "name": "Verify User Context?", + "description": "If True, the active user context of the resolved device is checked to ensure it matches the specified Spotify Connect user context. If False, the user context will not be checked. Default is False." + }, + "delay": { + "name": "Delay", + "description": "Time delay (in seconds) to wait AFTER issuing the final Connect command (if necessary). This delay will give the spotify web api time to process the device list change before another command is issued. Default is 0.50; value range is 0 - 10." + } + } + }, "player_media_play_context": { "name": "Player Media Play Context", "description": "Start playing one or more tracks of the specified context on a Spotify Connect device.", @@ -640,6 +707,28 @@ } } }, + "player_resolve_device_id": { + "name": "Player Resolve Device ID", + "description": "Resolves a Spotify Connect device identifier from a specified device id, name, alias id, or alias name. This will ensure that the device id can be found on the network, as well as connect to the device if necessary with the current user context. ", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Entity ID of the SpotifyPlus device that will make the request to the Spotify Web API." + }, + "device_value": { + "name": "Device Value", + "description": "The device id (e.g. '0d1841b0976bae2a3a310dd74c0f337465899bc8') or name (e.g. 'Bose-ST10-1') value to resolve." + }, + "verify_user_context": { + "name": "Verify User Context?", + "description": "If True, the active user context of the resolved device is checked to ensure it matches the user context specified on the class constructor. If False, the user context will not be checked. Default is True." + }, + "verify_timeout": { + "name": "Verify Timeout", + "description": "Time delay (in seconds) to wait AFTER issuing the final Connect command (if necessary). This delay will give the spotify web api time to process the device list change before another command is issued. Default is 5.0; value range is 0 - 10." + } + } + }, "player_transfer_playback": { "name": "Player Transfer Playback", "description": "Transfer playback to a new Spotify Connect device and optionally begin playback.", diff --git a/requirements.txt b/requirements.txt index c2cc4cb..d5f576c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ colorlog==6.7.0 homeassistant==2024.5.0 ruff==0.1.3 smartinspectPython>=3.0.33 -spotifywebapiPython>=1.0.48 +spotifywebapiPython>=1.0.59