Skip to content

Commit

Permalink
[ 1.0.60 ] * Added service player_media_seek to seek to the given a…
Browse files Browse the repository at this point in the history
…bsolute or relative position in the user's currently playing track for the specified Spotify Connect device.

  * Updated service `get_show_favorites` to include the `exclude_audiobooks` argument, which allows you to limit the results to only podcast items (by default).  For some reason, the Spotify Web API returns both audiobook and podcast items.
  * Updated Spotify Connect device processing to only require the userid and password configuration options when authenticating to devices that require user and password values.  Some devices only require a loginid value (e.g. spotifyd, Spotify Connect AddOn, librespot, Sonos, etc).  The user should not have to specify userid and password options if they are not required!
  * Updated service description strings to correct HASSFest validation errors on GitHub.
  * Updated underlying `spotifywebapiPython` package requirement to version 1.0.106.
  • Loading branch information
thlucas1 committed Oct 5, 2024
1 parent 0765d55 commit 2cd0654
Show file tree
Hide file tree
Showing 8 changed files with 362 additions and 68 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ Change are listed in reverse chronological order (newest to oldest).

<span class="changelog">

###### [ 1.0.60 ] - 2024/10/04

* Added service `player_media_seek` to seek to the given absolute or relative position in the user's currently playing track for the specified Spotify Connect device.
* Updated service `get_show_favorites` to include the `exclude_audiobooks` argument, which allows you to limit the results to only podcast items (by default). For some reason, the Spotify Web API returns both audiobook and podcast items.
* Updated Spotify Connect device processing to only require the userid and password configuration options when authenticating to devices that require user and password values. Some devices only require a loginid value (e.g. spotifyd, Spotify Connect AddOn, librespot, Sonos, etc). The user should not have to specify userid and password options if they are not required!
* Updated service description strings to correct HASSFest validation errors on GitHub.
* Updated underlying `spotifywebapiPython` package requirement to version 1.0.106.

###### [ 1.0.59 ] - 2024/09/28

* Added support for devices that utilize spotifyd / librespot. See the [wiki documentation](https://github.com/thlucas1/homeassistantcomponent_spotifyplus/wiki/Device-Configuration-Options#librespot-and-spotifyd-device-support) for more information on how to configure support for spotifyD / librespot applications.
Expand Down
34 changes: 33 additions & 1 deletion custom_components/spotifyplus/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@
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_MEDIA_SEEK:str = 'player_media_seek'
SERVICE_SPOTIFY_PLAYER_RESOLVE_DEVICE_ID:str = 'player_resolve_device_id'
SERVICE_SPOTIFY_PLAYER_SET_REPEAT_MODE:str = 'player_set_repeat_mode'
SERVICE_SPOTIFY_PLAYER_SET_SHUFFLE_MODE:str = 'player_set_shuffle_mode'
Expand Down Expand Up @@ -437,6 +438,7 @@
vol.Optional("offset", default=0): vol.All(vol.Range(min=0,max=500)),
vol.Optional("limit_total", default=0): vol.All(vol.Range(min=0,max=9999)),
vol.Optional("sort_result"): cv.boolean,
vol.Optional("exclude_audiobooks"): cv.boolean,
}
)

Expand Down Expand Up @@ -548,6 +550,16 @@
}
)

SERVICE_SPOTIFY_PLAYER_MEDIA_SEEK_SCHEMA = vol.Schema(
{
vol.Required("entity_id"): cv.entity_id,
vol.Optional("position_ms", default=-1): vol.All(vol.Range(min=-1,max=999999999)),
vol.Optional("device_id"): cv.string,
vol.Optional("delay", default=0.50): vol.All(vol.Range(min=0,max=10.0)),
vol.Optional("relative_position_ms", default=0): vol.All(vol.Range(min=-999999999,max=999999999)),
}
)

SERVICE_SPOTIFY_PLAYER_RESOLVE_DEVICE_ID_SCHEMA = vol.Schema(
{
vol.Required("entity_id"): cv.entity_id,
Expand Down Expand Up @@ -1020,6 +1032,16 @@ async def service_handle_spotify_command(service: ServiceCall) -> None:
_logsi.LogVerbose(STAppMessages.MSG_SERVICE_EXECUTE % (service.service, entity.name))
await hass.async_add_executor_job(entity.service_spotify_player_media_play_tracks, uris, position_ms, device_id, delay)

elif service.service == SERVICE_SPOTIFY_PLAYER_MEDIA_SEEK:

# seeks to the given position in the currently playing track.
position_ms = service.data.get("position_ms")
device_id = service.data.get("device_id")
delay = service.data.get("delay")
relative_position_ms = service.data.get("relative_position_ms")
_logsi.LogVerbose(STAppMessages.MSG_SERVICE_EXECUTE % (service.service, entity.name))
await hass.async_add_executor_job(entity.service_spotify_player_media_seek, position_ms, device_id, delay, relative_position_ms)

elif service.service == SERVICE_SPOTIFY_PLAYER_SET_REPEAT_MODE:

# set player repeat mode.
Expand Down Expand Up @@ -1492,8 +1514,9 @@ async def service_handle_spotify_serviceresponse(service: ServiceCall) -> Servic
offset = service.data.get("offset")
limit_total = service.data.get("limit_total")
sort_result = service.data.get("sort_result")
exclude_audiobooks = service.data.get("exclude_audiobooks")
_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, sort_result)
response = await hass.async_add_executor_job(entity.service_spotify_get_show_favorites, limit, offset, limit_total, sort_result, exclude_audiobooks)

elif service.service == SERVICE_SPOTIFY_GET_SPOTIFY_CONNECT_DEVICE:

Expand Down Expand Up @@ -2172,6 +2195,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_MEDIA_SEEK, SERVICE_SPOTIFY_PLAYER_MEDIA_SEEK_SCHEMA)
hass.services.async_register(
DOMAIN,
SERVICE_SPOTIFY_PLAYER_MEDIA_SEEK,
service_handle_spotify_command,
schema=SERVICE_SPOTIFY_PLAYER_MEDIA_SEEK_SCHEMA,
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,
Expand Down
4 changes: 2 additions & 2 deletions custom_components/spotifyplus/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@
"requests_oauthlib>=1.3.1",
"soco>=0.30.4",
"smartinspectPython>=3.0.33",
"spotifywebapiPython>=1.0.101",
"spotifywebapiPython>=1.0.106",
"urllib3>=1.21.1,<1.27",
"zeroconf>=0.132.2"
],
"version": "1.0.59",
"version": "1.0.60",
"zeroconf": [ "_spotify-connect._tcp.local." ]
}
138 changes: 137 additions & 1 deletion custom_components/spotifyplus/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -3768,6 +3768,7 @@ def service_spotify_get_show_favorites(
offset:int=0,
limitTotal:int=None,
sortResult:bool=True,
excludeAudiobooks:bool=True,
) -> dict:
"""
Get a list of the shows saved in the current Spotify user's 'Your Library'.
Expand All @@ -3790,6 +3791,15 @@ def service_spotify_get_show_favorites(
True to sort the items by name; otherwise, False to leave the items in the same order they
were returned in by the Spotify Web API.
Default: True
excludeAudiobooks (bool):
True to exclude audiobook shows from the returned list, leaving only podcast shows;
otherwise, False to include all results returned by the Spotify Web API.
Default: True
For some reason, Spotify Web API returns audiobooks AND podcasts with the `/me/shows` service.
Spotify Web API returns only audiobooks with the `/me/audiobooks` service.
The reasoning for that is unclear, but the `excludeAudiobooks` argument allows you to
only return podcast shows in the results if desired.
Returns:
A dictionary that contains the following keys:
Expand All @@ -3807,11 +3817,12 @@ def service_spotify_get_show_favorites(
apiMethodParms.AppendKeyValue("offset", offset)
apiMethodParms.AppendKeyValue("limitTotal", limitTotal)
apiMethodParms.AppendKeyValue("sortResult", sortResult)
apiMethodParms.AppendKeyValue("excludeAudiobooks", excludeAudiobooks)
_logsi.LogMethodParmList(SILevel.Verbose, "Spotify Get Show Favorites Service", apiMethodParms)

# request information from Spotify Web API.
_logsi.LogVerbose(STAppMessages.MSG_SERVICE_QUERY_WEB_API)
result:ShowPageSaved = self.data.spotifyClient.GetShowFavorites(limit, offset, limitTotal, sortResult)
result:ShowPageSaved = self.data.spotifyClient.GetShowFavorites(limit, offset, limitTotal, sortResult, excludeAudiobooks)

# return the (partial) user profile that retrieved the result, as well as the result itself.
return {
Expand Down Expand Up @@ -4819,6 +4830,131 @@ def service_spotify_player_media_play_tracks(
_logsi.LeaveMethod(SILevel.Debug, apiMethodName)


@spotify_exception_handler
def service_spotify_player_media_seek(
self,
positionMS:int=-1,
deviceId:str=None,
delay:float=0.50,
relativePositionMS:int=0,
) -> None:
"""
Seeks to the given absolute or relative position in the user's currently playing track
for the specified Spotify Connect device.
Args:
positionMS (int):
The absolute position in milliseconds to seek to; must be a positive number.
Passing in a position that is greater than the length of the track will cause the
player to start playing the next song.
Example = `25000` to start playing at the 25 second mark.
deviceId (str):
The id or name of the device this command is targeting.
If not supplied, the user's currently active device is the target.
Example: `0d1841b0976bae2a3a310dd74c0f3df354899bc8`
Example: `Web Player (Chrome)`
delay (float):
Time delay (in seconds) to wait AFTER issuing the command to the player.
This delay will give the spotify web api time to process the change before
another command is issued.
Default is 0.50; value range is 0 - 10.
relativePositionMS (int):
The relative position in milliseconds to seek to; can be a positive or negative number.
Example = `-10000` to seek behind by 10 seconds.
Example = `10000` to seek ahead by 10 seconds.
"""
apiMethodName:str = 'service_spotify_player_media_seek'
apiMethodParms:SIMethodParmListContext = None

try:

# trace.
apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
apiMethodParms.AppendKeyValue("positionMS", positionMS)
apiMethodParms.AppendKeyValue("deviceId", deviceId)
apiMethodParms.AppendKeyValue("delay", delay)
apiMethodParms.AppendKeyValue("relativePositionMS", positionMS)
_logsi.LogMethodParmList(SILevel.Verbose, "Spotify Player Media Seek Service", apiMethodParms)

# validations.
if positionMS == -1:
positionMS = None
if relativePositionMS == 0:
relativePositionMS = None

# validations.
delay = validateDelay(delay, 0.50, 10)
if deviceId == '':
deviceId = None
if deviceId is None or deviceId == "*":
deviceId = PlayerDevice.GetIdFromSelectItem(self.data.OptionDeviceDefault)

# get selected device reference from cached list of Spotify Connect devices.
scDevices:SpotifyConnectDevices = self.data.spotifyClient.GetSpotifyConnectDevices(refresh=False)
scDevice:SpotifyConnectDevice = scDevices.GetDeviceByName(deviceId)
if scDevice is None:
scDevice = scDevices.GetDeviceById(deviceId)

# get current track position.
# ignore what we get for device from _GetPlayerPlaybackState, as it's the active device
# and may be NOT what the user asked for (via deviceId argument).
playerState:PlayerPlayState
spotifyConnectDevice:SpotifyConnectDevice
sonosDevice:SoCo

# get current track position.
playerState, spotifyConnectDevice, sonosDevice = self._GetPlayerPlaybackState()

# set seek position based on device type.
if (sonosDevice is not None):

# was relative seeking specified?
if (relativePositionMS != 0) and ((positionMS is None) or (positionMS <= 0)):

newPositionMS:int = playerState.ProgressMS
if (newPositionMS is not None) and (newPositionMS > 0):

# calculate new position; if less than zero, then force it to zero.
newPositionMS += relativePositionMS
if (newPositionMS < 0):
newPositionMS = 0
positionMS = newPositionMS

# for Sonos, use the SoCo API command.
sonosPosition:str = positionHMS_fromMilliSeconds(positionMS) # convert from milliseconds to Sonos H:MM:SS format
_logsi.LogVerbose("'%s': Issuing command to Sonos device '%s' ('%s'): SEEK (position=%s)" % (self.name, sonosDevice.ip_address, sonosDevice.player_name, sonosPosition))
sonosDevice.seek(position=sonosPosition)

# give SoCo api time to process the change.
if delay > 0:
_logsi.LogVerbose(TRACE_MSG_DELAY_DEVICE_SONOS % delay)
time.sleep(delay)

else:

# for everything else, just use the Spotify Web API command.
self.data.spotifyClient.PlayerMediaSeek(positionMS, deviceId, delay, relativePositionMS)

# update ha state.
self.schedule_update_ha_state(force_refresh=False)

# media player command was processed, so force a scan window at the next interval.
_logsi.LogVerbose("'%s': Processed a media player command - forcing a playerState scan window for the next %d updates" % (self.name, SPOTIFY_SCAN_INTERVAL_COMMAND - 1))
self._commandScanInterval = SPOTIFY_SCAN_INTERVAL_COMMAND

# 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_player_resolve_device_id(
self,
deviceValue:str,
Expand Down
Loading

0 comments on commit 2cd0654

Please sign in to comment.