| 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.
| 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__.
Things:
device device1 "Device 1" [deviceName="<spotify device name>"]
device device2 "Device 2" [deviceName="<spotify device name>"]
+ Channels:
+ String : playlists [limit=50]
+ String : albumImageUrl [imageIndex=1]
}
```
spotify.items:
```
-Player spotifyTrackPlayer "Player" {channel="spotify:player:user1:trackPlayer"}
-String spotifyDevices "Active device [%s]" {channel="spotify:player:user1:devices"}
-Switch spotifyDeviceShuffle "Shuffle mode" {channel="spotify:player:user1:deviceShuffle"}
-String spotifyTrackRepeat "Repeat mode: [%s]" {channel="spotify:player:user1:trackRepeat"}
-String spotifyTrackProgress "Track progress: [%s]" {channel="spotify:player:user1:trackProgress"}
-String spotifyTrackDuration "Track duration: [%s]" {channel="spotify:player:user1:trackDuration"}
-String spotifyTrackName "Track Name: [%s]" {channel="spotify:player:user1:trackName"}
-String spotifyAlbumName "Album Name: [%s]" {channel="spotify:player:user1:albumName"}
-String spotifyArtistName "Artist Name: [%s]" {channel="spotify:player:user1:artistName"}
-Image spotifyAlbumImage "Album Art" {channel="spotify:player:user1:albumImage"}
-String spotifyPlaylists "Playlists [%s]" {channel="spotify:player:user1:playlists"}
-String spotifyPlayName "Playlist [%s]" {channel="spotify:player:user1:playlistName"}
+Player spotifyTrackPlayer "Player" {channel="spotify:player:user1:trackPlayer"}
+String spotifyDevices "Active device [%s]" {channel="spotify:player:user1:devices"}
+Switch spotifyDeviceShuffle "Shuffle mode" {channel="spotify:player:user1:deviceShuffle"}
+String spotifyTrackRepeat "Repeat mode: [%s]" {channel="spotify:player:user1:trackRepeat"}
+String spotifyTrackProgress "Track progress: [%s]" {channel="spotify:player:user1:trackProgress"}
+String spotifyTrackDuration "Track duration: [%s]" {channel="spotify:player:user1:trackDuration"}
+String spotifyTrackName "Track Name: [%s]" {channel="spotify:player:user1:trackName"}
+String spotifyAlbumName "Album Name: [%s]" {channel="spotify:player:user1:albumName"}
+String spotifyArtistName "Artist Name: [%s]" {channel="spotify:player:user1:artistName"}
+String spotifyAlbumImageUrl "Album Art" {channel="spotify:player:user1:albumImageUrl"}
+String spotifyPlaylists "Playlists [%s]" {channel="spotify:player:user1:playlists"}
+String spotifyPlayName "Playlist [%s]" {channel="spotify:player:user1:playlistName"}
String device1DeviceName {channel="spotify:device:user1:device1:deviceName"}
Player device1Player {channel="spotify:device:user1:device1:devicePlayer"}
Text item=spotifyTrackProgress label="Track progress: [%s]"
Text item=spotifyTrackDuration label="Track duration: [%s]"
Text item=spotifyTrackName label="Track Name: [%s]"
- Image item=spotifyAlbumImage label="Album Art"
+ Image item=spotifyAlbumImageUrl label="Album Art"
Text item=spotifyAlbumName label="Currently Played Album Name: [%s]"
Text item=spotifyArtistName label="Currently Played Artist Name: [%s]"
Selection item=spotifyPlaylists label="Playlist" icon="music"
}
```
+spotify.rules
+
+```
+val spotifyActions = getActions("spotify", "spotify:player:user1")
+// play the song
+spotifyActions.play("spotify:track:4cOdK2wGLETKBW3PvgPWqT")
+```
+
## Binding model and Spotify Web API
The model of the binding is such that the bridge acts as a player in the context of a specific user.
public static final String CHANNEL_PLAYLISTS = "playlists";
public static final String CHANNEL_PLAYLISTNAME = "playlistName";
+ public static final String CHANNEL_PLAYLISTS_LIMIT = "limit";
+ public static final String CHANNEL_PLAYLISTS_OFFSET = "offset";
public static final String CHANNEL_PLAYED_TRACKID = "trackId";
public static final String CHANNEL_PLAYED_TRACKURI = "trackUri";
public static final String CHANNEL_PLAYED_ALBUMNAME = "albumName";
public static final String CHANNEL_PLAYED_ALBUMTYPE = "albumType";
public static final String CHANNEL_PLAYED_ALBUMIMAGE = "albumImage";
+ public static final String CHANNEL_PLAYED_ALBUMIMAGEURL = "albumImageUrl";
+ public static final String CHANNEL_CONFIG_IMAGE_INDEX = "imageIndex";
public static final String CHANNEL_PLAYED_ARTISTID = "artistId";
public static final String CHANNEL_PLAYED_ARTISTURI = "artistUri";
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.spotify.internal.actions;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.spotify.internal.handler.SpotifyBridgeHandler;
+import org.openhab.core.automation.annotation.ActionInput;
+import org.openhab.core.automation.annotation.RuleAction;
+import org.openhab.core.thing.binding.ThingActions;
+import org.openhab.core.thing.binding.ThingActionsScope;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+
+/**
+ * Spotify Rule Actions.
+ *
+ * @author Hilbrand Bouwkamp - Initial contribution
+ */
+@ThingActionsScope(name = "spotify")
+@NonNullByDefault
+public class SpotifyActions implements ThingActions, ThingHandlerService {
+
+ private @Nullable ThingHandler handler;
+
+ /**
+ * Play a context uri (track or other) on the current active device (if null is passed for deviceID) or the given
+ * device at the given offset and/or position in milliseconds.
+ *
+ * @param actions Spotify Actions object.
+ * @param contextUri context uri (track or other)
+ * @param deviceId Id of the device to play on, or current device if given null
+ * @param offset Offset in the list, default 0.
+ * @param positionMs position in the track in milliseconds, default 0,
+ */
+ @RuleAction(label = "@text/actions.play.label", description = "@text/actions.play.description")
+ public void play(
+ @ActionInput(name = "contextUri", label = "@text/actions.play.context_uri.label", description = "@text/actions.play.context_uri.description", type = "java.lang.String", required = true) String contextUri,
+ @ActionInput(name = "deviceId", label = "@text/actions.play.device_id.label", description = "@text/actions.play.device_id.description", type = "java.lang.String", defaultValue = "") @Nullable String deviceId,
+ @ActionInput(name = "offset", label = "@text/actions.play.offset.label", description = "@text/actions.play.offset.description", type = "java.lang.Integer", defaultValue = "0") final int offset,
+ @ActionInput(name = "positionMs", label = "@text/actions.play.positions_ms.label", description = "@text/actions.play.positions_ms.description", type = "java.lang.Integer", defaultValue = "0") final int positionMs) {
+ ((SpotifyBridgeHandler) getThingHandler()).getSpotifyApi().playTrack(deviceId == null ? "" : deviceId,
+ contextUri, offset, positionMs);
+ }
+
+ /**
+ * Play a context uri (track or other) on the current active device.
+ *
+ * @param actions Spotify Actions object.
+ * @param contextUri context uri (track or other)
+ */
+ public static void play(ThingActions actions, String contextUri) {
+ ((SpotifyActions) actions).play(contextUri, null, 0, 0);
+ }
+
+ /**
+ * Play a context uri (track or other) on the current active device at the given offset and/or position in
+ * milliseconds.
+ *
+ * @param actions Spotify Actions object.
+ * @param contextUri context uri (track or other)
+ * @param offset Offset in the list, default 0.
+ * @param positionMs position in the track in milliseconds, default 0,
+ */
+ public static void play(ThingActions actions, String contextUri, final int offset, final int positionMs) {
+ ((SpotifyActions) actions).play(contextUri, null, positionMs, positionMs);
+ }
+
+ /**
+ * Play a context uri (track or other) on the given device.
+ *
+ * @param actions Spotify Actions object.
+ * @param contextUri context uri (track or other)
+ * @param deviceId Id of the device to play on, or current device if given null
+ */
+ public static void play(ThingActions actions, String contextUri, @Nullable String deviceId) {
+ ((SpotifyActions) actions).play(contextUri, deviceId, 0, 0);
+ }
+
+ /**
+ * Play a context uri (track or other) on the current active device (if null is passed for deviceID) or the given
+ * device at the given offset and/or position in milliseconds.
+ *
+ * @param actions Spotify Actions object.
+ * @param contextUri context uri (track or other)
+ * @param deviceId Id of the device to play on, or current device if given null
+ * @param offset Offset in the list, default 0.
+ * @param positionMs position in the track in milliseconds, default 0,
+ */
+ public static void play(ThingActions actions, String contextUri, @Nullable String deviceId, final int offset,
+ final int positionMs) {
+ ((SpotifyActions) actions).play(contextUri, deviceId, positionMs, positionMs);
+ }
+
+ @Override
+ public void setThingHandler(ThingHandler handler) {
+ this.handler = handler;
+ }
+
+ @Override
+ public @Nullable ThingHandler getThingHandler() {
+ return handler;
+ }
+}
*/
package org.openhab.binding.spotify.internal.api;
-import static org.eclipse.jetty.http.HttpMethod.*;
-import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.*;
+import static org.eclipse.jetty.http.HttpMethod.GET;
+import static org.eclipse.jetty.http.HttpMethod.POST;
+import static org.eclipse.jetty.http.HttpMethod.PUT;
+import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.SPOTIFY_API_PLAYER_URL;
+import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.SPOTIFY_API_URL;
import java.io.IOException;
import java.util.Arrays;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import com.google.gson.JsonSyntaxException;
+
/**
* Class to handle Spotify Web Api calls.
*
private static final char AMP = '&';
private static final char QSM = '?';
private static final CurrentlyPlayingContext EMPTY_CURRENTLYPLAYINGCONTEXT = new CurrentlyPlayingContext();
- private static final String PLAY_TRACK_URIS = "{\"uris\":[%s],\"offset\":{\"position\":0}}";
- private static final String PLAY_TRACK_CONTEXT_URI = "{\"context_uri\":\"%s\",\"offset\":{\"position\":0}}";
+ private static final String PLAY_TRACK_URIS = "{\"uris\":[%s],\"offset\":{\"position\":%d},\"position_ms\":%d}";
+ private static final String PLAY_TRACK_CONTEXT_URI = "{\"context_uri\":\"%s\",\"offset\":{\"position\":%d},\"position_ms\":%d}}";
private static final String TRANSFER_PLAY = "{\"device_ids\":[\"%s\"],\"play\":%b}";
private final Logger logger = LoggerFactory.getLogger(SpotifyApi.class);
* @return Returns the Spotify user information
*/
public Me getMe() {
- final ContentResponse response = request(GET, SPOTIFY_API_URL, "");
-
- return Objects.requireNonNull(ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Me.class));
+ return Objects.requireNonNull(request(GET, SPOTIFY_API_URL, "", Me.class));
}
/**
*
* @param deviceId device to play on or empty if play on the active device
* @param trackId id of the track to play
+ * @param offset offset
+ * @param positionMs position in ms
*/
- public void playTrack(String deviceId, String trackId) {
+ public void playTrack(String deviceId, String trackId, int offset, int positionMs) {
final String url = "play" + optionalDeviceId(deviceId, QSM);
final String play;
if (trackId.contains(":track:")) {
- play = String.format(PLAY_TRACK_URIS, Arrays.asList(trackId.split(",")).stream().map(t -> '"' + 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);
}
/**
* @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);
}
/**
* 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));
* @return Calls Spotify Api and returns the list of device or an empty list if nothing was returned
*/
public List<Device> 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();
/**
* @return Returns the playlists of the user.
*/
- public List<Playlist> getPlaylists() {
- final ContentResponse response = request(GET, SPOTIFY_API_URL + "/playlists", "");
- final Playlists playlists = ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Playlists.class);
+ public List<Playlist> 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();
}
* 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;
}
* 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);
}
/**
* 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 <T> @Nullable T requestPlayer(HttpMethod method, String url, String requestData, Class<T> clazz) {
+ return request(method, SPOTIFY_API_PLAYER_URL + (url.isEmpty() ? "" : ('/' + url)), requestData, clazz);
+ }
+
+ /**
+ * Parses the Spotify returned json.
+ *
+ * @param <T> 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 <T> @Nullable T fromJson(String content, Class<T> 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 <T> @Nullable T request(HttpMethod method, String url, String requestData, Class<T> clazz) {
logger.debug("Request: ({}) {} - {}", method, url, requestData);
final Function<HttpClient, Request> call = httpClient -> httpClient.newRequest(url).method(method)
.header("Accept", CONTENT_TYPE).content(new StringContentProvider(requestData), CONTENT_TYPE);
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);
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());
}
*/
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;
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);
@Override
public void activate() {
- Map<String, Object> properties = new HashMap<>();
+ final Map<String, Object> properties = new HashMap<>();
properties.put(DiscoveryService.CONFIG_PROPERTY_BACKGROUND_DISCOVERY, Boolean.TRUE);
super.activate(properties);
}
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<String, Object> properties = new HashMap<>();
+ final Map<String, Object> 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);
}
*/
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;
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;
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) {
@Override
public Collection<Class<? extends ThingHandlerService>> getServices() {
- return Collections.singleton(SpotifyDeviceDiscoveryService.class);
+ return List.of(SpotifyActions.class, SpotifyDeviceDiscoveryService.class);
}
@Override
&& 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());
}
}
@Nullable
- SpotifyApi getSpotifyApi() {
+ public SpotifyApi getSpotifyApi() {
return spotifyApi;
}
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 "";
}
} 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);
}
}
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(() -> {
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
if (pollStatus() && pollingNotRunning) {
startPolling();
}
- } catch (RuntimeException e) {
+ } catch (final RuntimeException e) {
logger.debug("Restarting polling failed: ", e);
}
}
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<Device> ld = devicesCache.getValue();
final List<Device> devices = ld == null ? Collections.emptyList() : ld;
spotifyDynamicStateDescriptionProvider.setDevices(devicesChannelUID, devices);
}
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);
.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.
*
* @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<Image> 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<Image> 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.
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);
}
}
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;
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;
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+ <config-description uri="spotify:config:playlists">
+ <parameter name="limit" type="integer" min="1" max="50">
+ <label>Limit</label>
+ <description>The maximum number of playlists to return</description>
+ <default>20</default>
+ </parameter>
+ <parameter name="offset" type="integer" min="0" max="100000">
+ <label>Offset</label>
+ <description>The index of the first playlist to return</description>
+ <default>0</default>
+ </parameter>
+ </config-description>
+ <config-description uri="spotify:config:album-image">
+ <parameter name="imageIndex" type="integer" min="0" max="2">
+ <label>Image Index</label>
+ <description>Index in list of to select image to show</description>
+ <default>0</default>
+ </parameter>
+ </config-description>
+</config-description:config-descriptions>
--- /dev/null
+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)
<channel id="albumHref" typeId="currentlyPlayedAlbumHref"/>
<channel id="albumType" typeId="currentlyPlayedAlbumType"/>
<channel id="albumImage" typeId="currentlyPlayedAlbumImage"/>
+ <channel id="albumImageUrl" typeId="currentlyPlayedAlbumImage"/>
<channel id="albumName" typeId="currentlyPlayedAlbumName"/>
<channel id="artistId" typeId="currentlyPlayedArtistId"/>
<default>10</default>
<label>Connect Refresh Period (seconds)</label>
<description>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.</description>
+ 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.</description>
</parameter>
</config-description>
</bridge-type>
<item-type>String</item-type>
<label>Playlists</label>
<description>List of the users playlists</description>
+ <config-description-ref uri="spotify:config:playlists"/>
</channel-type>
<channel-type id="playlistName">
<label>Album Image</label>
<description>The cover art for the album in widest size</description>
<state readOnly="true"/>
+ <config-description-ref uri="spotify:config:album-image"/>
+ </channel-type>
+ <channel-type id="currentlyPlayedAlbumImageUrl">
+ <item-type>String</item-type>
+ <label>Album Image URL</label>
+ <description>The URL to the cover art for the album in widest size</description>
+ <state readOnly="true"/>
+ <config-description-ref uri="spotify:config:album-image"/>
</channel-type>
<channel-type id="currentlyPlayedArtistId" advanced="true">