From 61fe39f020a91f40d86638e193079f668e17ec09 Mon Sep 17 00:00:00 2001 From: Hilbrand Bouwkamp Date: Mon, 11 Oct 2021 16:45:01 +0200 Subject: [PATCH] [spotify] Various enhancements and fixes Enhancements: - Added play actions to start a track or other via a rule. - Added albumImageUrl with the url to the album image to not have to send the whole image via the openHAB eventbus. - Added a parameter imageIndex to set the image size to use (0, 1 or 2) to channels albumImage and albumImageUrl. - Added offset and limit parameters to playlists channel (Closes #6843). Fixes: - Fixed invalid expire value set on ExpiringCache (Closes #10490). - Improved handling Spotify API error messages (Closes #11308). - Added TTL to discovery results to fix duplicated Spotify Connect devices reported in the inbox (Closes #7400). - Fixed device list update problem for devices. If no devices where configured as separate things (or the devices added where offline, but non added where not offline) the list was not updated. Signed-off-by: Hilbrand Bouwkamp --- bundles/org.openhab.binding.spotify/README.md | 64 +++++++-- .../internal/SpotifyBindingConstants.java | 4 + .../internal/actions/SpotifyActions.java | 114 +++++++++++++++++ .../spotify/internal/api/SpotifyApi.java | 94 +++++++++----- .../SpotifyDeviceDiscoveryService.java | 18 ++- .../handler/SpotifyBridgeHandler.java | 121 ++++++++++++++---- .../handler/SpotifyHandleCommands.java | 4 +- .../main/resources/OH-INF/config/config.xml | 25 ++++ .../OH-INF/i18n/spotify_en.properties | 10 ++ .../resources/OH-INF/thing/thing-types.xml | 16 ++- 10 files changed, 381 insertions(+), 89 deletions(-) create mode 100644 bundles/org.openhab.binding.spotify/src/main/java/org/openhab/binding/spotify/internal/actions/SpotifyActions.java create mode 100644 bundles/org.openhab.binding.spotify/src/main/resources/OH-INF/config/config.xml create mode 100644 bundles/org.openhab.binding.spotify/src/main/resources/OH-INF/i18n/spotify_en.properties diff --git a/bundles/org.openhab.binding.spotify/README.md b/bundles/org.openhab.binding.spotify/README.md index 50ee99678cb39..59f5fe197cb87 100644 --- a/bundles/org.openhab.binding.spotify/README.md +++ b/bundles/org.openhab.binding.spotify/README.md @@ -109,8 +109,22 @@ __Common Channels:__ | playlistName | String | Read-write | The currently playing playlist. Or empty if no playing list is playing. | | albumName | String | Read-only | Album Name of the currently playing track. | | albumImage | RawType | Read-only | Album Image of the currently playing track. | +| albumImageUrl | String | Read-only | Url to the album Image of the currently playing track. | | artistName | String | Read-only | Artist Name of the currently playing track. | +The `playlists` channel has 2 parameters: + +| Parameter | Description | +|-----------|----------------------------------------------------------------------------| +| offset | The index of the first playlist to return. Default `0`, max `100.000` | +| limit | The maximum number of playlists to return. Default `20`, min `1`, max `50` | + +The `albumImage` and `albumImageUrl` channels has 1 parameter: + +| Parameter | Description | +|------------|--------------------------------------------------------------------------------------------| +| imageIndex | Index in list of to select size of the image to show. 0:large (default), 1:medium, 2:small | + Note: The `deviceName` and `playlist` channels are Selection channels. They are dynamically populated by the binding with the user specific devices and playlists. @@ -163,6 +177,19 @@ __Advanced Channels:__ | deviceActive | Switch | Read-only | Indicates if the device is active or not. Should be the same as Thing status ONLINE/OFFLINE. | | deviceRestricted | Switch | Read-only | Indicates if this device allows to be controlled by the API or not. If restricted it cannot be controlled. | +### Actions + +The bridge supports an action to play a track or other context uri. +The following actions are supported: + +``` +play(String context_uri) +play(String context_uri, int offset, int position_ms) +play(String context_uri, String device_id) +play(String context_uri, String device_id, int offset, int position_ms) +``` + + ## Full Example In this example there is a bridge configured with Thing ID __user1__ and illustrating that the bridge is authorized to play in the context of the Spotify user account __user1__. @@ -174,24 +201,27 @@ Bridge spotify:player:user1 "Me" [clientId="", clientSecret=" '"' + t + '"') - .collect(Collectors.joining(","))); + play = String.format(PLAY_TRACK_URIS, + Arrays.asList(trackId.split(",")).stream().map(t -> '"' + t + '"').collect(Collectors.joining(",")), + offset, positionMs); } else { - play = String.format(PLAY_TRACK_CONTEXT_URI, trackId); + play = String.format(PLAY_TRACK_CONTEXT_URI, trackId, offset, positionMs); } - requestPlayer(PUT, url, play); + requestPlayer(PUT, url, play, String.class); } /** @@ -127,7 +134,7 @@ public void play(String deviceId) { * @param play if true transfers and starts to play, else transfers but pauses. */ public void transferPlay(String deviceId, boolean play) { - requestPlayer(PUT, "", String.format(TRANSFER_PLAY, deviceId, play)); + requestPlayer(PUT, "", String.format(TRANSFER_PLAY, deviceId, play), String.class); } /** @@ -174,7 +181,7 @@ public void setVolume(String deviceId, int volumePercent) { * active device. * * @param deviceId device to set repeat state on or empty if set repeat on the active device - * @param repeateState set the spotify repeat state + * @param repeateState set the Spotify repeat state */ public void setRepeatState(String deviceId, String repeateState) { requestPlayer(PUT, String.format("repeat?state=%s", repeateState) + optionalDeviceId(deviceId, AMP)); @@ -208,8 +215,7 @@ private String optionalDeviceId(String deviceId, char prefix) { * @return Calls Spotify Api and returns the list of device or an empty list if nothing was returned */ public List getDevices() { - final ContentResponse response = requestPlayer(GET, "devices"); - final Devices deviceList = ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Devices.class); + final Devices deviceList = requestPlayer(GET, "devices", "", Devices.class); return deviceList == null || deviceList.getDevices() == null ? Collections.emptyList() : deviceList.getDevices(); @@ -218,9 +224,9 @@ public List getDevices() { /** * @return Returns the playlists of the user. */ - public List getPlaylists() { - final ContentResponse response = request(GET, SPOTIFY_API_URL + "/playlists", ""); - final Playlists playlists = ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Playlists.class); + public List getPlaylists(int offset, int limit) { + final Playlists playlists = request(GET, SPOTIFY_API_URL + "/playlists?offset" + offset + "&limit=" + limit, "", + Playlists.class); return playlists == null || playlists.getItems() == null ? Collections.emptyList() : playlists.getItems(); } @@ -230,9 +236,7 @@ public List getPlaylists() { * returned by Spotify */ public CurrentlyPlayingContext getPlayerInfo() { - final ContentResponse response = requestPlayer(GET, ""); - final CurrentlyPlayingContext context = ModelUtil.gsonInstance().fromJson(response.getContentAsString(), - CurrentlyPlayingContext.class); + final CurrentlyPlayingContext context = requestPlayer(GET, "", "", CurrentlyPlayingContext.class); return context == null ? EMPTY_CURRENTLYPLAYINGCONTEXT : context; } @@ -242,11 +246,10 @@ public CurrentlyPlayingContext getPlayerInfo() { * Spotify. * * @param method Http method to perform - * @param url url path to call to spotify - * @return the response give by Spotify + * @param url url path to call to Spotify */ - private ContentResponse requestPlayer(HttpMethod method, String url) { - return requestPlayer(method, url, ""); + private void requestPlayer(HttpMethod method, String url) { + requestPlayer(method, url, "", String.class); } /** @@ -254,23 +257,42 @@ private ContentResponse requestPlayer(HttpMethod method, String url) { * Spotify. * * @param method Http method to perform - * @param url url path to call to spotify + * @param url url path to call to Spotify * @param requestData data to pass along with the call as content + * @param clazz data type of return data, if null no data is expected to be returned. * @return the response give by Spotify */ - private ContentResponse requestPlayer(HttpMethod method, String url, String requestData) { - return request(method, SPOTIFY_API_PLAYER_URL + (url.isEmpty() ? "" : ('/' + url)), requestData); + private @Nullable T requestPlayer(HttpMethod method, String url, String requestData, Class clazz) { + return request(method, SPOTIFY_API_PLAYER_URL + (url.isEmpty() ? "" : ('/' + url)), requestData, clazz); + } + + /** + * Parses the Spotify returned json. + * + * @param z data type to return + * @param content json content to parse + * @param clazz data type to return + * @throws SpotifyException throws a {@link SpotifyException} in case the json could not be parsed. + * @return parsed json. + */ + private static @Nullable T fromJson(String content, Class clazz) { + try { + return (T) ModelUtil.gsonInstance().fromJson(content, clazz); + } catch (final JsonSyntaxException e) { + throw new SpotifyException("Unknown Spotify response:" + content, e); + } } /** * Calls the Spotify Web Api with the given method and given url as parameters of the call to Spotify. * * @param method Http method to perform - * @param url url path to call to spotify + * @param url url path to call to Spotify * @param requestData data to pass along with the call as content + * @param clazz data type of return data, if null no data is expected to be returned. * @return the response give by Spotify */ - private ContentResponse request(HttpMethod method, String url, String requestData) { + private @Nullable T request(HttpMethod method, String url, String requestData, Class clazz) { logger.debug("Request: ({}) {} - {}", method, url, requestData); final Function call = httpClient -> httpClient.newRequest(url).method(method) .header("Accept", CONTENT_TYPE).content(new StringContentProvider(requestData), CONTENT_TYPE); @@ -280,11 +302,13 @@ private ContentResponse request(HttpMethod method, String url, String requestDat if (accessToken == null || accessToken.isEmpty()) { throw new SpotifyAuthorizationException( - "No spotify accesstoken. Did you authorize spotify via /connectspotify ?"); + "No Spotify accesstoken. Did you authorize Spotify via /connectspotify ?"); } else { - return requestWithRetry(call, accessToken); + final String response = requestWithRetry(call, accessToken).getContentAsString(); + + return clazz == String.class ? (@Nullable T) response : fromJson(response, clazz); } - } catch (IOException e) { + } catch (final IOException e) { throw new SpotifyException(e.getMessage(), e); } catch (OAuthException | OAuthResponseException e) { throw new SpotifyAuthorizationException(e.getMessage(), e); @@ -295,7 +319,7 @@ private ContentResponse requestWithRetry(final Function cal throws OAuthException, IOException, OAuthResponseException { try { return connector.request(call, BEARER + accessToken); - } catch (SpotifyTokenExpiredException e) { + } catch (final SpotifyTokenExpiredException e) { // Retry with new access token return connector.request(call, BEARER + oAuthClientService.refreshToken().getAccessToken()); } diff --git a/bundles/org.openhab.binding.spotify/src/main/java/org/openhab/binding/spotify/internal/discovery/SpotifyDeviceDiscoveryService.java b/bundles/org.openhab.binding.spotify/src/main/java/org/openhab/binding/spotify/internal/discovery/SpotifyDeviceDiscoveryService.java index d84b6fad0c73f..b6f6872a5258e 100644 --- a/bundles/org.openhab.binding.spotify/src/main/java/org/openhab/binding/spotify/internal/discovery/SpotifyDeviceDiscoveryService.java +++ b/bundles/org.openhab.binding.spotify/src/main/java/org/openhab/binding/spotify/internal/discovery/SpotifyDeviceDiscoveryService.java @@ -12,8 +12,10 @@ */ package org.openhab.binding.spotify.internal.discovery; -import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.*; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.PROPERTY_SPOTIFY_DEVICE_NAME; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.THING_TYPE_DEVICE; +import java.time.Duration; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -55,6 +57,8 @@ public class SpotifyDeviceDiscoveryService extends AbstractDiscoveryService private static final int DISCOVERY_TIME_SECONDS = 10; // Check every minute for new devices private static final long BACKGROUND_SCAN_REFRESH_MINUTES = 1; + // Time to life for discovered things. + private static final long TTL_SECONDS = Duration.ofHours(1).toSeconds(); private final Logger logger = LoggerFactory.getLogger(SpotifyDeviceDiscoveryService.class); @@ -74,7 +78,7 @@ public Set getSupportedThingTypes() { @Override public void activate() { - Map properties = new HashMap<>(); + final Map properties = new HashMap<>(); properties.put(DiscoveryService.CONFIG_PROPERTY_BACKGROUND_DISCOVERY, Boolean.TRUE); super.activate(properties); } @@ -120,22 +124,22 @@ protected void startScan() { logger.debug("Starting Spotify Device discovery for bridge {}", bridgeUID); try { bridgeHandler.listDevices().forEach(this::thingDiscovered); - } catch (RuntimeException e) { + } catch (final RuntimeException e) { logger.debug("Finding devices failed with message: {}", e.getMessage(), e); } } } private void thingDiscovered(Device device) { - Map properties = new HashMap<>(); + final Map properties = new HashMap<>(); properties.put(PROPERTY_SPOTIFY_DEVICE_NAME, device.getName()); - ThingUID thing = new ThingUID(SpotifyBindingConstants.THING_TYPE_DEVICE, bridgeUID, + final ThingUID thing = new ThingUID(SpotifyBindingConstants.THING_TYPE_DEVICE, bridgeUID, device.getId().substring(0, PLAYER_ID_LENGTH)); - DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thing).withBridge(bridgeUID) + final DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thing).withBridge(bridgeUID) .withProperties(properties).withRepresentationProperty(PROPERTY_SPOTIFY_DEVICE_NAME) - .withLabel(device.getName()).build(); + .withTTL(TTL_SECONDS).withLabel(device.getName()).build(); thingDiscovered(discoveryResult); } diff --git a/bundles/org.openhab.binding.spotify/src/main/java/org/openhab/binding/spotify/internal/handler/SpotifyBridgeHandler.java b/bundles/org.openhab.binding.spotify/src/main/java/org/openhab/binding/spotify/internal/handler/SpotifyBridgeHandler.java index 961c85d180213..08c126ca66fdd 100644 --- a/bundles/org.openhab.binding.spotify/src/main/java/org/openhab/binding/spotify/internal/handler/SpotifyBridgeHandler.java +++ b/bundles/org.openhab.binding.spotify/src/main/java/org/openhab/binding/spotify/internal/handler/SpotifyBridgeHandler.java @@ -12,9 +12,53 @@ */ package org.openhab.binding.spotify.internal.handler; -import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.*; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_ACCESSTOKEN; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_CONFIG_IMAGE_INDEX; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICEACTIVE; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICEID; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICENAME; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICES; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICESHUFFLE; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICETYPE; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICEVOLUME; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMHREF; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMID; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMIMAGE; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMIMAGEURL; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMNAME; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMTYPE; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMURI; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ARTISTHREF; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ARTISTID; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ARTISTNAME; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ARTISTTYPE; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ARTISTURI; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKDISCNUMBER; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKDURATION_FMT; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKDURATION_MS; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKEXPLICIT; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKHREF; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKID; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKNAME; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKNUMBER; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKPOPULARITY; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKPROGRESS_FMT; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKPROGRESS_MS; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKTYPE; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKURI; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYLISTNAME; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYLISTS; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYLISTS_LIMIT; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYLISTS_OFFSET; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_TRACKPLAYER; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_TRACKREPEAT; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.PROPERTY_SPOTIFY_USER; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.SPOTIFY_API_TOKEN_URL; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.SPOTIFY_AUTHORIZE_URL; +import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.SPOTIFY_SCOPES; import java.io.IOException; +import java.math.BigDecimal; import java.text.SimpleDateFormat; import java.time.Duration; import java.util.Collection; @@ -31,6 +75,7 @@ import org.eclipse.jetty.client.HttpClient; import org.openhab.binding.spotify.internal.SpotifyAccountHandler; import org.openhab.binding.spotify.internal.SpotifyBridgeConfiguration; +import org.openhab.binding.spotify.internal.actions.SpotifyActions; import org.openhab.binding.spotify.internal.api.SpotifyApi; import org.openhab.binding.spotify.internal.api.exception.SpotifyAuthorizationException; import org.openhab.binding.spotify.internal.api.exception.SpotifyException; @@ -133,6 +178,8 @@ public class SpotifyBridgeHandler extends BaseBridgeHandler private volatile State lastTrackId = StringType.EMPTY; private volatile String lastKnownDeviceId = ""; private volatile boolean lastKnownDeviceActive; + private int imageChannelAlbumImageIndex; + private int imageChannelAlbumImageUrlIndex; public SpotifyBridgeHandler(Bridge bridge, OAuthFactory oAuthFactory, HttpClient httpClient, SpotifyDynamicStateDescriptionProvider spotifyDynamicStateDescriptionProvider) { @@ -146,7 +193,7 @@ public SpotifyBridgeHandler(Bridge bridge, OAuthFactory oAuthFactory, HttpClient @Override public Collection> getServices() { - return Collections.singleton(SpotifyDeviceDiscoveryService.class); + return List.of(SpotifyActions.class, SpotifyDeviceDiscoveryService.class); } @Override @@ -172,7 +219,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { && handleCommand.handleCommand(channelUID, command, lastKnownDeviceActive, lastKnownDeviceId)) { scheduler.schedule(this::scheduledPollingRestart, POLL_DELAY_AFTER_COMMAND_S, TimeUnit.SECONDS); } - } catch (SpotifyException e) { + } catch (final SpotifyException e) { logger.debug("Handle Spotify command failed: ", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage()); } @@ -227,7 +274,7 @@ public boolean isOnline() { } @Nullable - SpotifyApi getSpotifyApi() { + public SpotifyApi getSpotifyApi() { return spotifyApi; } @@ -240,7 +287,7 @@ public boolean equalsThingUID(String thingUID) { public String formatAuthorizationUrl(String redirectUri) { try { return oAuthService.getAuthorizationUrl(redirectUri, null, thing.getUID().getAsString()); - } catch (OAuthException e) { + } catch (final OAuthException e) { logger.debug("Error constructing AuthorizationUrl: ", e); return ""; } @@ -259,7 +306,7 @@ public String authorize(String redirectUri, String reqCode) { } catch (RuntimeException | OAuthException | IOException e) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage()); throw new SpotifyException(e.getMessage(), e); - } catch (OAuthResponseException e) { + } catch (final OAuthResponseException e) { throw new SpotifyAuthorizationException(e.getMessage(), e); } } @@ -287,9 +334,13 @@ public void initialize() { oAuthService.addAccessTokenRefreshListener(SpotifyBridgeHandler.this); spotifyApi = new SpotifyApi(oAuthService, scheduler, httpClient); handleCommand = new SpotifyHandleCommands(spotifyApi); - playingContextCache = new ExpiringCache<>(configuration.refreshPeriod, spotifyApi::getPlayerInfo); - playlistCache = new ExpiringCache<>(POLL_PLAY_LIST_HOURS, spotifyApi::getPlaylists); - devicesCache = new ExpiringCache<>(configuration.refreshPeriod, spotifyApi::getDevices); + final Duration expiringPeriod = Duration.ofSeconds(configuration.refreshPeriod); + + playingContextCache = new ExpiringCache<>(expiringPeriod, spotifyApi::getPlayerInfo); + final int offset = getIntChannelParameter(CHANNEL_PLAYLISTS, CHANNEL_PLAYLISTS_OFFSET, 0); + final int limit = getIntChannelParameter(CHANNEL_PLAYLISTS, CHANNEL_PLAYLISTS_LIMIT, 20); + playlistCache = new ExpiringCache<>(POLL_PLAY_LIST_HOURS, () -> spotifyApi.getPlaylists(offset, limit)); + devicesCache = new ExpiringCache<>(expiringPeriod, spotifyApi::getDevices); // Start with update status by calling Spotify. If no credentials available no polling should be started. scheduler.execute(() -> { @@ -297,6 +348,16 @@ public void initialize() { startPolling(); } }); + imageChannelAlbumImageIndex = getIntChannelParameter(CHANNEL_PLAYED_ALBUMIMAGE, CHANNEL_CONFIG_IMAGE_INDEX, 0); + imageChannelAlbumImageUrlIndex = getIntChannelParameter(CHANNEL_PLAYED_ALBUMIMAGEURL, + CHANNEL_CONFIG_IMAGE_INDEX, 0); + } + + private int getIntChannelParameter(String channelName, String parameter, int _default) { + final Channel channel = thing.getChannel(channelName); + final BigDecimal index = channel == null ? null : (BigDecimal) channel.getConfiguration().get(parameter); + + return index == null ? _default : index.intValue(); } @Override @@ -318,7 +379,7 @@ private void scheduledPollingRestart() { if (pollStatus() && pollingNotRunning) { startPolling(); } - } catch (RuntimeException e) { + } catch (final RuntimeException e) { logger.debug("Restarting polling failed: ", e); } } @@ -363,7 +424,7 @@ private boolean pollStatus() { final CurrentlyPlayingContext playingContext = pc == null ? EMPTY_CURRENTLY_PLAYING_CONTEXT : pc; // Collect devices and populate selection with available devices. - if (hasPlayData || hasAnyDeviceStatusUnknown()) { + if (hasPlayData) { final List ld = devicesCache.getValue(); final List devices = ld == null ? Collections.emptyList() : ld; spotifyDynamicStateDescriptionProvider.setDevices(devicesChannelUID, devices); @@ -381,17 +442,17 @@ private boolean pollStatus() { } updateStatus(ThingStatus.ONLINE); return true; - } catch (SpotifyAuthorizationException e) { + } catch (final SpotifyAuthorizationException e) { logger.debug("Authorization error during polling: ", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage()); cancelSchedulers(); devicesCache.invalidateValue(); - } catch (SpotifyException e) { + } catch (final SpotifyException e) { logger.info("Spotify returned an error during polling: {}", e.getMessage()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); - } catch (RuntimeException e) { + } catch (final RuntimeException e) { // This only should catch RuntimeException as the apiCall don't throw other exceptions. logger.info("Unexpected error during polling status, please report if this keeps occurring: ", e); @@ -431,12 +492,6 @@ private void updateDevicesStatus(List spotifyDevices, boolean playing) { .forEach(thing -> ((SpotifyDeviceHandler) thing.getHandler()).setStatusGone()); } - private boolean hasAnyDeviceStatusUnknown() { - return getThing().getThings().stream() // - .filter(thing -> thing.getHandler() instanceof SpotifyDeviceHandler) // - .anyMatch(sd -> ((SpotifyDeviceHandler) sd.getHandler()).getThing().getStatus() == ThingStatus.UNKNOWN); - } - /** * Update the player data. * @@ -651,22 +706,32 @@ private class AlbumUpdater { * @param album album data */ public void updateAlbumImage(Album album) { - final Channel channel = thing.getChannel(CHANNEL_PLAYED_ALBUMIMAGE); + final Channel imageChannel = thing.getChannel(CHANNEL_PLAYED_ALBUMIMAGE); final List images = album.getImages(); - if (channel != null && images != null && !images.isEmpty()) { - final String imageUrl = images.get(0).getUrl(); + // Update album image url channel + final String albumImageUrlUrl = albumUrl(images, imageChannelAlbumImageUrlIndex); + updateChannelState(CHANNEL_PLAYED_ALBUMIMAGEURL, + albumImageUrlUrl == null ? UnDefType.UNDEF : StringType.valueOf(albumImageUrlUrl)); - if (!lastAlbumImageUrl.equals(imageUrl)) { + // Trigger image refresh of album image channel + final String albumImageUrl = albumUrl(images, imageChannelAlbumImageIndex); + if (imageChannel != null && albumImageUrl != null) { + if (!lastAlbumImageUrl.equals(albumImageUrl)) { // Download the cover art in a different thread to not delay the other operations - lastAlbumImageUrl = imageUrl == null ? "" : imageUrl; - refreshAlbumImage(channel.getUID()); - } + lastAlbumImageUrl = albumImageUrl; + refreshAlbumImage(imageChannel.getUID()); + } // else album image still the same so nothing to do } else { + lastAlbumImageUrl = ""; updateChannelState(CHANNEL_PLAYED_ALBUMIMAGE, UnDefType.UNDEF); } } + private @Nullable String albumUrl(@Nullable List images, int index) { + return images == null || index >= images.size() || images.isEmpty() ? null : images.get(index).getUrl(); + } + /** * Refreshes the image asynchronously, but only downloads the image if the channel is linked to avoid * unnecessary downloading of the image. @@ -686,7 +751,7 @@ private void refreshAlbumAsynced(ChannelUID channelUID, String imageUrl) { final RawType image = HttpUtil.downloadImage(imageUrl, true, MAX_IMAGE_SIZE); updateChannelState(CHANNEL_PLAYED_ALBUMIMAGE, image == null ? UnDefType.UNDEF : image); } - } catch (RuntimeException e) { + } catch (final RuntimeException e) { logger.debug("Async call to refresh Album image failed: ", e); } } diff --git a/bundles/org.openhab.binding.spotify/src/main/java/org/openhab/binding/spotify/internal/handler/SpotifyHandleCommands.java b/bundles/org.openhab.binding.spotify/src/main/java/org/openhab/binding/spotify/internal/handler/SpotifyHandleCommands.java index 2de7f94ffd729..e5c80dc092628 100644 --- a/bundles/org.openhab.binding.spotify/src/main/java/org/openhab/binding/spotify/internal/handler/SpotifyHandleCommands.java +++ b/bundles/org.openhab.binding.spotify/src/main/java/org/openhab/binding/spotify/internal/handler/SpotifyHandleCommands.java @@ -123,7 +123,7 @@ public boolean handleCommand(ChannelUID channelUID, Command command, boolean act case CHANNEL_TRACKPLAY: case CHANNEL_PLAYLISTS: if (command instanceof StringType) { - spotifyApi.playTrack(deviceId, command.toString()); + spotifyApi.playTrack(deviceId, command.toString(), 0, 0); commandRun = true; } break; @@ -132,7 +132,7 @@ public boolean handleCommand(ChannelUID channelUID, Command command, boolean act final String newName = command.toString(); playlists.stream().filter(pl -> pl.getName().equals(newName)).findFirst() - .ifPresent(pl -> spotifyApi.playTrack(deviceId, pl.getUri())); + .ifPresent(pl -> spotifyApi.playTrack(deviceId, pl.getUri(), 0, 0)); commandRun = true; } break; diff --git a/bundles/org.openhab.binding.spotify/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.spotify/src/main/resources/OH-INF/config/config.xml new file mode 100644 index 0000000000000..6802e8d78309f --- /dev/null +++ b/bundles/org.openhab.binding.spotify/src/main/resources/OH-INF/config/config.xml @@ -0,0 +1,25 @@ + + + + + + The maximum number of playlists to return + 20 + + + + The index of the first playlist to return + 0 + + + + + + Index in list of to select image to show + 0 + + + diff --git a/bundles/org.openhab.binding.spotify/src/main/resources/OH-INF/i18n/spotify_en.properties b/bundles/org.openhab.binding.spotify/src/main/resources/OH-INF/i18n/spotify_en.properties new file mode 100644 index 0000000000000..f3a26ce8c0d02 --- /dev/null +++ b/bundles/org.openhab.binding.spotify/src/main/resources/OH-INF/i18n/spotify_en.properties @@ -0,0 +1,10 @@ +actions.play.label=Play +actions.play.description=Play the given Spotify uri +actions.play.context_uri.label=Context URI +actions.play.context_uri.description=The context uri or a comma separated list of uris +actions.play.device_id.label=Device Id +actions.play.device_id.description=Id of the device to play. If omitted will play on the current active device (Optional) +actions.play.offset.label=offset +actions.play.offset.description=Offset to start (Optional). +actions.play.positions_ms.label=Position ms +actions.play.positions_ms.description=Position in milliseconds to start (Optional) diff --git a/bundles/org.openhab.binding.spotify/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.spotify/src/main/resources/OH-INF/thing/thing-types.xml index 949b5231eee66..b591562ad5ec2 100644 --- a/bundles/org.openhab.binding.spotify/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.spotify/src/main/resources/OH-INF/thing/thing-types.xml @@ -48,6 +48,7 @@ + @@ -78,10 +79,8 @@ 10 This is the frequency of the polling requests to the Spotify Connect Web API. There are limits to the - number of requests - that can be sent to the Web API. The more often you poll, the better status updates - at the risk - of running out of - your request quota. + number of requests that can be sent to the Web API. The more often you poll, the better status updates - at the + risk of running out of your request quota. @@ -159,6 +158,7 @@ String List of the users playlists + @@ -293,6 +293,14 @@ The cover art for the album in widest size + + + + String + + The URL to the cover art for the album in widest size + +