added migrated 2.x add-ons

Signed-off-by: Kai Kreuzer <kai@openhab.org>
This commit is contained in:
Kai Kreuzer
2020-09-21 01:58:32 +02:00
parent bbf1a7fd29
commit 6df6783b60
11662 changed files with 1302875 additions and 11 deletions

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.spotify-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-spotify" description="Spotify Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<feature>openhab-transport-http</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.spotify/${project.version}</bundle>
</feature>
</features>

View File

@@ -0,0 +1,84 @@
/**
* Copyright (c) 2010-2020 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;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.spotify.internal.api.model.Device;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.ThingHandler;
/**
* Interface to decouple Spotify Bridge Handler implementation from other code.
*
* @author Hilbrand Bouwkamp - Initial contribution
*/
@NonNullByDefault
public interface SpotifyAccountHandler extends ThingHandler {
/**
* @return The {@link ThingUID} associated with this Spotify Account Handler
*/
ThingUID getUID();
/**
* @return The label of the Spotify Bridge associated with this Spotify Account Handler
*/
String getLabel();
/**
* @return The Spotify user name associated with this Spotify Account Handler
*/
String getUser();
/**
* @return Returns true if the Spotify Bridge is authorized.
*/
boolean isAuthorized();
/**
* @return List of Spotify devices associated with this Spotify Account Handler
*/
List<Device> listDevices();
/**
* @return Returns true if the device is online
*/
boolean isOnline();
/**
* Calls Spotify Api to obtain refresh and access tokens and persist data with Thing.
*
* @param redirectUrl The redirect url Spotify calls back to
* @param reqCode The unique code passed by Spotify to obtain the refresh and access tokens
* @return returns the name of the Spotify user that is authorized
*/
String authorize(String redirectUrl, String reqCode);
/**
* Returns true if the given Thing UID relates to this {@link SpotifyAccountHandler} instance.
*
* @param thingUID The Thing UID to check
* @return true if it relates to the given Thing UID
*/
boolean equalsThingUID(String thingUID);
/**
* Formats the Url to use to call Spotify to authorize the application.
*
* @param redirectUri The uri Spotify will redirect back to
* @return the formatted url that should be used to call Spotify Web Api with
*/
String formatAuthorizationUrl(String redirectUri);
}

View File

@@ -0,0 +1,180 @@
/**
* Copyright (c) 2010-2020 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;
import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.*;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import org.apache.commons.io.IOUtils;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.spotify.internal.api.exception.SpotifyException;
import org.osgi.framework.BundleContext;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.http.HttpService;
import org.osgi.service.http.NamespaceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link SpotifyAuthService} class to manage the servlets and bind authorization servlet to bridges.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Made this the service class instead of only interface. Added templates
*/
@Component(service = SpotifyAuthService.class, immediate = true, configurationPid = "binding.spotify.authService")
@NonNullByDefault
public class SpotifyAuthService {
private static final String TEMPLATE_PATH = "templates/";
private static final String TEMPLATE_PLAYER = TEMPLATE_PATH + "player.html";
private static final String TEMPLATE_INDEX = TEMPLATE_PATH + "index.html";
private static final String ERROR_UKNOWN_BRIDGE = "Returned 'state' by doesn't match any Bridges. Has the bridge been removed?";
private final Logger logger = LoggerFactory.getLogger(SpotifyAuthService.class);
private final List<SpotifyAccountHandler> handlers = new ArrayList<>();
private @NonNullByDefault({}) HttpService httpService;
private @NonNullByDefault({}) BundleContext bundleContext;
@Activate
protected void activate(ComponentContext componentContext, Map<String, Object> properties) {
try {
bundleContext = componentContext.getBundleContext();
httpService.registerServlet(SPOTIFY_ALIAS, createServlet(), new Hashtable<>(),
httpService.createDefaultHttpContext());
httpService.registerResources(SPOTIFY_ALIAS + SPOTIFY_IMG_ALIAS, "web", null);
} catch (NamespaceException | ServletException | IOException e) {
logger.warn("Error during spotify servlet startup", e);
}
}
@Deactivate
protected void deactivate(ComponentContext componentContext) {
httpService.unregister(SPOTIFY_ALIAS);
httpService.unregister(SPOTIFY_ALIAS + SPOTIFY_IMG_ALIAS);
}
/**
* Creates a new {@link SpotifyAuthServlet}.
*
* @return the newly created servlet
* @throws IOException thrown when an HTML template could not be read
*/
private HttpServlet createServlet() throws IOException {
return new SpotifyAuthServlet(this, readTemplate(TEMPLATE_INDEX), readTemplate(TEMPLATE_PLAYER));
}
/**
* Reads a template from file and returns the content as String.
*
* @param templateName name of the template file to read
* @return The content of the template file
* @throws IOException thrown when an HTML template could not be read
*/
private String readTemplate(String templateName) throws IOException {
final URL index = bundleContext.getBundle().getEntry(templateName);
if (index == null) {
throw new FileNotFoundException(
String.format("Cannot find '{}' - failed to initialize Spotify servlet", templateName));
} else {
try (InputStream inputStream = index.openStream()) {
return IOUtils.toString(inputStream);
}
}
}
/**
* Call with Spotify redirect uri returned State and Code values to get the refresh and access tokens and persist
* these values
*
* @param servletBaseURL the servlet base, which will be the Spotify redirect url
* @param state The Spotify returned state value
* @param code The Spotify returned code value
* @return returns the name of the Spotify user that is authorized
*/
public String authorize(String servletBaseURL, String state, String code) {
final SpotifyAccountHandler listener = getSpotifyAuthListener(state);
if (listener == null) {
logger.debug(
"Spotify redirected with state '{}' but no matching bridge was found. Possible bridge has been removed.",
state);
throw new SpotifyException(ERROR_UKNOWN_BRIDGE);
} else {
return listener.authorize(servletBaseURL, code);
}
}
/**
* @param listener Adds the given handler
*/
public void addSpotifyAccountHandler(SpotifyAccountHandler listener) {
if (!handlers.contains(listener)) {
handlers.add(listener);
}
}
/**
* @param handler Removes the given handler
*/
public void removeSpotifyAccountHandler(SpotifyAccountHandler handler) {
handlers.remove(handler);
}
/**
* @return Returns all {@link SpotifyAccountHandler}s.
*/
public List<SpotifyAccountHandler> getSpotifyAccountHandlers() {
return handlers;
}
/**
* Get the {@link SpotifyAccountHandler} that matches the given thing UID.
*
* @param thingUID UID of the thing to match the handler with
* @return the {@link SpotifyAccountHandler} matching the thing UID or null
*/
private @Nullable SpotifyAccountHandler getSpotifyAuthListener(String thingUID) {
final Optional<SpotifyAccountHandler> maybeListener = handlers.stream().filter(l -> l.equalsThingUID(thingUID))
.findFirst();
return maybeListener.isPresent() ? maybeListener.get() : null;
}
@Reference
protected void setHttpService(HttpService httpService) {
this.httpService = httpService;
}
protected void unsetHttpService(HttpService httpService) {
this.httpService = null;
}
}

View File

@@ -0,0 +1,207 @@
/**
* Copyright (c) 2010-2020 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;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.util.MultiMap;
import org.eclipse.jetty.util.StringUtil;
import org.eclipse.jetty.util.UrlEncoded;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link SpotifyAuthServlet} manages the authorization with the Spotify Web API. The servlet implements the
* Authorization Code flow and saves the resulting refreshToken with the bridge.
*
* @author Andreas Stenlund - Initial contribution
* @author Matthew Bowman - Initial contribution
* @author Hilbrand Bouwkamp - Rewrite, moved service part to service class. Uses templates, simplified calls.
*/
@NonNullByDefault
public class SpotifyAuthServlet extends HttpServlet {
private static final long serialVersionUID = -4719613645562518231L;
private static final String CONTENT_TYPE = "text/html;charset=UTF-8";
// Simple HTML templates for inserting messages.
private static final String HTML_EMPTY_PLAYERS = "<p class='block'>Manually add a Spotify Player Bridge to authorize it here.<p>";
private static final String HTML_USER_AUTHORIZED = "<p class='block authorized'>Bridge authorized for user %s.</p>";
private static final String HTML_ERROR = "<p class='block error'>Call to Spotify failed with error: %s</p>";
private static final Pattern MESSAGE_KEY_PATTERN = Pattern.compile("\\$\\{([^\\}]+)\\}");
// Keys present in the index.html
private static final String KEY_PAGE_REFRESH = "pageRefresh";
private static final String HTML_META_REFRESH_CONTENT = "<meta http-equiv='refresh' content='10; url=%s'>";
private static final String KEY_AUTHORIZED_USER = "authorizedUser";
private static final String KEY_ERROR = "error";
private static final String KEY_PLAYERS = "players";
private static final String KEY_REDIRECT_URI = "redirectUri";
// Keys present in the player.html
private static final String PLAYER_ID = "player.id";
private static final String PLAYER_NAME = "player.name";
private static final String PLAYER_SPOTIFY_USER_ID = "player.user";
private static final String PLAYER_AUTHORIZED_CLASS = "player.authorized";
private static final String PLAYER_AUTHORIZE = "player.authorize";
private final Logger logger = LoggerFactory.getLogger(SpotifyAuthServlet.class);
private final SpotifyAuthService spotifyAuthService;
private final String indexTemplate;
private final String playerTemplate;
public SpotifyAuthServlet(SpotifyAuthService spotifyAuthService, String indexTemplate, String playerTemplate) {
this.spotifyAuthService = spotifyAuthService;
this.indexTemplate = indexTemplate;
this.playerTemplate = playerTemplate;
}
@Override
protected void doGet(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp)
throws ServletException, IOException {
logger.debug("Spotify auth callback servlet received GET request {}.", req.getRequestURI());
final String servletBaseURL = req.getRequestURL().toString();
final Map<String, String> replaceMap = new HashMap<>();
handleSpotifyRedirect(replaceMap, servletBaseURL, req.getQueryString());
resp.setContentType(CONTENT_TYPE);
replaceMap.put(KEY_REDIRECT_URI, servletBaseURL);
replaceMap.put(KEY_PLAYERS, formatPlayers(playerTemplate, servletBaseURL));
resp.getWriter().append(replaceKeysFromMap(indexTemplate, replaceMap));
resp.getWriter().close();
}
/**
* Handles a possible call from Spotify to the redirect_uri. If that is the case Spotify will pass the authorization
* codes via the url and these are processed. In case of an error this is shown to the user. If the user was
* authorized this is passed on to the handler. Based on all these different outcomes the HTML is generated to
* inform the user.
*
* @param replaceMap a map with key String values that will be mapped in the HTML templates.
* @param servletBaseURL the servlet base, which should be used as the Spotify redirect_uri value
* @param queryString the query part of the GET request this servlet is processing
*/
private void handleSpotifyRedirect(Map<String, String> replaceMap, String servletBaseURL,
@Nullable String queryString) {
replaceMap.put(KEY_AUTHORIZED_USER, "");
replaceMap.put(KEY_ERROR, "");
replaceMap.put(KEY_PAGE_REFRESH, "");
if (queryString != null) {
final MultiMap<String> params = new MultiMap<>();
UrlEncoded.decodeTo(queryString, params, StandardCharsets.UTF_8.name());
final String reqCode = params.getString("code");
final String reqState = params.getString("state");
final String reqError = params.getString("error");
replaceMap.put(KEY_PAGE_REFRESH,
params.isEmpty() ? "" : String.format(HTML_META_REFRESH_CONTENT, servletBaseURL));
if (!StringUtil.isBlank(reqError)) {
logger.debug("Spotify redirected with an error: {}", reqError);
replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, reqError));
} else if (!StringUtil.isBlank(reqState)) {
try {
replaceMap.put(KEY_AUTHORIZED_USER, String.format(HTML_USER_AUTHORIZED,
spotifyAuthService.authorize(servletBaseURL, reqState, reqCode)));
} catch (RuntimeException e) {
logger.debug("Exception during authorizaton: ", e);
replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, e.getMessage()));
}
}
}
}
/**
* Formats the HTML of all available Spotify Bridge Players and returns it as a String
*
* @param playerTemplate The player template to format the player values in
* @param servletBaseURL the redirect_uri to be used in the authorization url created on the authorization button.
* @return A String with the players formatted with the player template
*/
private String formatPlayers(String playerTemplate, String servletBaseURL) {
final List<SpotifyAccountHandler> players = spotifyAuthService.getSpotifyAccountHandlers();
return players.isEmpty() ? HTML_EMPTY_PLAYERS
: players.stream().map(p -> formatPlayer(playerTemplate, p, servletBaseURL))
.collect(Collectors.joining());
}
/**
* Formats the HTML of a Spotify Bridge Player and returns it as a String
*
* @param playerTemplate The player template to format the player values in
* @param handler The handler for the player to format
* @param servletBaseURL the redirect_uri to be used in the authorization url created on the authorization button.
* @return A String with the player formatted with the player template
*/
private String formatPlayer(String playerTemplate, SpotifyAccountHandler handler, String servletBaseURL) {
final Map<String, String> map = new HashMap<>();
map.put(PLAYER_ID, handler.getUID().getAsString());
map.put(PLAYER_NAME, handler.getLabel());
final String spotifyUser = handler.getUser();
if (handler.isAuthorized()) {
map.put(PLAYER_AUTHORIZED_CLASS, " authorized");
map.put(PLAYER_SPOTIFY_USER_ID, String.format(" (Authorized user: %s)", spotifyUser));
} else if (!StringUtil.isBlank(spotifyUser)) {
map.put(PLAYER_AUTHORIZED_CLASS, " Unauthorized");
map.put(PLAYER_SPOTIFY_USER_ID, String.format(" (Unauthorized user: %s)", spotifyUser));
} else {
map.put(PLAYER_AUTHORIZED_CLASS, "");
map.put(PLAYER_SPOTIFY_USER_ID, "");
}
map.put(PLAYER_AUTHORIZE, handler.formatAuthorizationUrl(servletBaseURL));
return replaceKeysFromMap(playerTemplate, map);
}
/**
* Replaces all keys from the map found in the template with values from the map. If the key is not found the key
* will be kept in the template.
*
* @param template template to replace keys with values
* @param map map with key value pairs to replace in the template
* @return a template with keys replaced
*/
private String replaceKeysFromMap(String template, Map<String, String> map) {
final Matcher m = MESSAGE_KEY_PATTERN.matcher(template);
final StringBuffer sb = new StringBuffer();
while (m.find()) {
try {
final String key = m.group(1);
m.appendReplacement(sb, Matcher.quoteReplacement(map.getOrDefault(key, "${" + key + '}')));
} catch (RuntimeException e) {
logger.debug("Error occurred during template filling, cause ", e);
}
}
m.appendTail(sb);
return sb.toString();
}
}

View File

@@ -0,0 +1,102 @@
/**
* Copyright (c) 2010-2020 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;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link SpotifyBindingConstants} class defines common constants, which are used across the whole binding.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Added more constants
*/
public class SpotifyBindingConstants {
// List of Spotify services related urls, information
public static final String SPOTIFY_ACCOUNT_URL = "https://accounts.spotify.com";
public static final String SPOTIFY_AUTHORIZE_URL = SPOTIFY_ACCOUNT_URL + "/authorize";
public static final String SPOTIFY_API_TOKEN_URL = SPOTIFY_ACCOUNT_URL + "/api/token";
/**
* Spotify scopes needed by this binding to work.
*/
public static final String SPOTIFY_SCOPES = Stream.of("user-read-playback-state", "user-modify-playback-state",
"playlist-read-private", "playlist-read-collaborative").collect(Collectors.joining(" "));
public static final String SPOTIFY_API_URL = "https://api.spotify.com/v1/me";
public static final String SPOTIFY_API_PLAYER_URL = SPOTIFY_API_URL + "/player";
// Authorization related Servlet and resources aliases.
public static final String SPOTIFY_ALIAS = "/connectspotify";
public static final String SPOTIFY_IMG_ALIAS = "/img";
// List of all Thing Type UIDs
private static final String BINDING_ID = "spotify";
public static final ThingTypeUID THING_TYPE_PLAYER = new ThingTypeUID(BINDING_ID, "player");
public static final ThingTypeUID THING_TYPE_DEVICE = new ThingTypeUID(BINDING_ID, "device");
// List of all Channel ids
public static final String CHANNEL_ACCESSTOKEN = "accessToken";
public static final String CHANNEL_TRACKPLAY = "trackPlay";
public static final String CHANNEL_TRACKPLAYER = "trackPlayer";
public static final String CHANNEL_TRACKREPEAT = "trackRepeat";
public static final String CHANNEL_PLAYLISTS = "playlists";
public static final String CHANNEL_PLAYLISTNAME = "playlistName";
public static final String CHANNEL_PLAYED_TRACKID = "trackId";
public static final String CHANNEL_PLAYED_TRACKURI = "trackUri";
public static final String CHANNEL_PLAYED_TRACKHREF = "trackHref";
public static final String CHANNEL_PLAYED_TRACKNAME = "trackName";
public static final String CHANNEL_PLAYED_TRACKTYPE = "trackType";
public static final String CHANNEL_PLAYED_TRACKNUMBER = "trackNumber";
public static final String CHANNEL_PLAYED_TRACKDISCNUMBER = "trackDiscNumber";
public static final String CHANNEL_PLAYED_TRACKPOPULARITY = "trackPopularity";
public static final String CHANNEL_PLAYED_TRACKEXPLICIT = "trackExplicit";
public static final String CHANNEL_PLAYED_TRACKDURATION_MS = "trackDurationMs";
public static final String CHANNEL_PLAYED_TRACKPROGRESS_MS = "trackProgressMs";
public static final String CHANNEL_PLAYED_TRACKDURATION_FMT = "trackDuration";
public static final String CHANNEL_PLAYED_TRACKPROGRESS_FMT = "trackProgress";
public static final String CHANNEL_PLAYED_ALBUMID = "albumId";
public static final String CHANNEL_PLAYED_ALBUMURI = "albumUri";
public static final String CHANNEL_PLAYED_ALBUMHREF = "albumHref";
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_ARTISTID = "artistId";
public static final String CHANNEL_PLAYED_ARTISTURI = "artistUri";
public static final String CHANNEL_PLAYED_ARTISTHREF = "artistHref";
public static final String CHANNEL_PLAYED_ARTISTNAME = "artistName";
public static final String CHANNEL_PLAYED_ARTISTTYPE = "artistType";
public static final String CHANNEL_DEVICEID = "deviceId";
public static final String CHANNEL_DEVICES = "devices";
public static final String CHANNEL_DEVICENAME = "deviceName";
public static final String CHANNEL_DEVICETYPE = "deviceType";
public static final String CHANNEL_DEVICEACTIVE = "deviceActive";
public static final String CHANNEL_DEVICERESTRICTED = "deviceRestricted";
public static final String CHANNEL_DEVICEVOLUME = "deviceVolume";
public static final String CHANNEL_DEVICESHUFFLE = "deviceShuffle";
public static final String CHANNEL_DEVICEPLAYER = "devicePlayer";
// List of Bridge configuration params
public static final String CONFIGURATION_CLIENT_ID = "clientId";
// List of Bridge/Thing properties
public static final String PROPERTY_SPOTIFY_USER = "user";
public static final String PROPERTY_SPOTIFY_DEVICE_NAME = "deviceName";
}

View File

@@ -0,0 +1,25 @@
/**
* Copyright (c) 2010-2020 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;
/**
* Configuration class for Spotify Bridge.
*
* @author Hilbrand Bouwkamp - Initial contribution
*/
public class SpotifyBridgeConfiguration {
public String clientId;
public String clientSecret;
public int refreshPeriod;
}

View File

@@ -0,0 +1,88 @@
/**
* Copyright (c) 2010-2020 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;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.spotify.internal.handler.SpotifyBridgeHandler;
import org.openhab.binding.spotify.internal.handler.SpotifyDeviceHandler;
import org.openhab.binding.spotify.internal.handler.SpotifyDynamicStateDescriptionProvider;
import org.openhab.core.auth.client.oauth2.OAuthFactory;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* The {@link SpotifyHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author Andreas Stenlund - Initial contribution
* @author Matthew Bowman - Initial contribution
* @author Hilbrand Bouwkamp - Added registration of discovery service to binding to this class
*/
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.spotify")
@NonNullByDefault
public class SpotifyHandlerFactory extends BaseThingHandlerFactory {
private final OAuthFactory oAuthFactory;
private final HttpClient httpClient;
private final SpotifyAuthService authService;
private final SpotifyDynamicStateDescriptionProvider spotifyDynamicStateDescriptionProvider;
@Activate
public SpotifyHandlerFactory(@Reference OAuthFactory oAuthFactory,
@Reference final HttpClientFactory httpClientFactory, @Reference SpotifyAuthService authService,
@Reference SpotifyDynamicStateDescriptionProvider spotifyDynamicStateDescriptionProvider) {
this.oAuthFactory = oAuthFactory;
this.httpClient = httpClientFactory.getCommonHttpClient();
this.authService = authService;
this.spotifyDynamicStateDescriptionProvider = spotifyDynamicStateDescriptionProvider;
}
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SpotifyBindingConstants.THING_TYPE_PLAYER.equals(thingTypeUID)
|| SpotifyBindingConstants.THING_TYPE_DEVICE.equals(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
final ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (SpotifyBindingConstants.THING_TYPE_PLAYER.equals(thingTypeUID)) {
final SpotifyBridgeHandler handler = new SpotifyBridgeHandler((Bridge) thing, oAuthFactory, httpClient,
spotifyDynamicStateDescriptionProvider);
authService.addSpotifyAccountHandler(handler);
return handler;
}
if (SpotifyBindingConstants.THING_TYPE_DEVICE.equals(thingTypeUID)) {
return new SpotifyDeviceHandler(thing);
}
return null;
}
@Override
protected synchronized void removeHandler(ThingHandler thingHandler) {
if (thingHandler instanceof SpotifyBridgeHandler) {
authService.removeSpotifyAccountHandler((SpotifyBridgeHandler) thingHandler);
}
}
}

View File

@@ -0,0 +1,302 @@
/**
* Copyright (c) 2010-2020 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.api;
import static org.eclipse.jetty.http.HttpMethod.*;
import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.*;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpMethod;
import org.openhab.binding.spotify.internal.api.exception.SpotifyAuthorizationException;
import org.openhab.binding.spotify.internal.api.exception.SpotifyException;
import org.openhab.binding.spotify.internal.api.exception.SpotifyTokenExpiredException;
import org.openhab.binding.spotify.internal.api.model.CurrentlyPlayingContext;
import org.openhab.binding.spotify.internal.api.model.Device;
import org.openhab.binding.spotify.internal.api.model.Devices;
import org.openhab.binding.spotify.internal.api.model.Me;
import org.openhab.binding.spotify.internal.api.model.ModelUtil;
import org.openhab.binding.spotify.internal.api.model.Playlist;
import org.openhab.binding.spotify.internal.api.model.Playlists;
import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
import org.openhab.core.auth.client.oauth2.OAuthClientService;
import org.openhab.core.auth.client.oauth2.OAuthException;
import org.openhab.core.auth.client.oauth2.OAuthResponseException;
import org.openhab.core.library.types.OnOffType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Class to handle Spotify Web Api calls.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Refactored calling Web Api and simplified code
*/
@NonNullByDefault
public class SpotifyApi {
private static final String CONTENT_TYPE = "application/json";
private static final String BEARER = "Bearer ";
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 TRANSFER_PLAY = "{\"device_ids\":[\"%s\"],\"play\":%b}";
private final Logger logger = LoggerFactory.getLogger(SpotifyApi.class);
private final OAuthClientService oAuthClientService;
private final SpotifyConnector connector;
/**
* Constructor.
*
* @param authorizer The authorizer used to refresh the access token when expired
* @param connector The Spotify connector handling the Web Api calls to Spotify
*/
public SpotifyApi(OAuthClientService oAuthClientService, ScheduledExecutorService scheduler,
HttpClient httpClient) {
this.oAuthClientService = oAuthClientService;
connector = new SpotifyConnector(scheduler, httpClient);
}
/**
* @return Returns the Spotify user information
*/
public Me getMe() {
final ContentResponse response = request(GET, SPOTIFY_API_URL, "");
return ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Me.class);
}
/**
* Call Spotify Api to play the given track on the given device. If the device id is empty it will be played on
* the active device.
*
* @param deviceId device to play on or empty if play on the active device
* @param trackId id of the track to play
*/
public void playTrack(String deviceId, String trackId) {
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(",")));
} else {
play = String.format(PLAY_TRACK_CONTEXT_URI, trackId);
}
requestPlayer(PUT, url, play);
}
/**
* Call Spotify Api to start playing. If the device id is empty it will start play of the active device.
*
* @param deviceId device to play on or empty if play on the active device
*/
public void play(String deviceId) {
requestPlayer(PUT, "play" + optionalDeviceId(deviceId, QSM));
}
/**
* Call Spotify Api to transfer playing to. Depending on play value is start play or pause.
*
* @param deviceId device to play on. It can not be empty.
* @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));
}
/**
* Call Spotify Api to pause playing. If the device id is empty it will pause play of the active device.
*
* @param deviceId device to pause on or empty if pause on the active device
*/
public void pause(String deviceId) {
requestPlayer(PUT, "pause" + optionalDeviceId(deviceId, QSM));
}
/**
* Call Spotify Api to play the next song. If the device id is empty it will play the next song on the active
* device.
*
* @param deviceId device to play next track on or empty if play next track on the active device
*/
public void next(String deviceId) {
requestPlayer(POST, "next" + optionalDeviceId(deviceId, QSM));
}
/**
* Call Spotify Api to play the previous song. If the device id is empty it will play the previous song on the
* active device.
*
* @param deviceId device to play previous track on or empty if play previous track on the active device
*/
public void previous(String deviceId) {
requestPlayer(POST, "previous" + optionalDeviceId(deviceId, QSM));
}
/**
* Call Spotify Api to play set the volume. If the device id is empty it will set the volume on the active device.
*
* @param deviceId device to set the Volume on or empty if set volume on the active device
* @param volumePercent volume percentage value to set
*/
public void setVolume(String deviceId, int volumePercent) {
requestPlayer(PUT, String.format("volume?volume_percent=%1d", volumePercent) + optionalDeviceId(deviceId, AMP));
}
/**
* Call Spotify Api to play set the repeat state. If the device id is empty it will set the repeat state on the
* 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
*/
public void setRepeatState(String deviceId, String repeateState) {
requestPlayer(PUT, String.format("repeat?state=%s", repeateState) + optionalDeviceId(deviceId, AMP));
}
/**
* Call Spotify Api to play set the shuffle. If the device id is empty it will set shuffle state on the active
* device.
*
* @param deviceId device to set shuffle state on or empty if set shuffle on the active device
* @param state the shuffle state to set
*/
public void setShuffleState(String deviceId, OnOffType state) {
requestPlayer(PUT, String.format("shuffle?state=%s", state == OnOffType.OFF ? "false" : "true")
+ optionalDeviceId(deviceId, AMP));
}
/**
* Method to return an optional device id url pattern. If device id is empty an empty string is returned else the
* device id url query pattern prefixed with the given prefix char
*
* @param deviceId device to play on or empty if play on the active device
* @param prefix char to prefix to the deviceId string if present
* @return empty string or query string part for device id
*/
private String optionalDeviceId(String deviceId, char prefix) {
return deviceId.isEmpty() ? "" : String.format("%cdevice_id=%s", prefix, deviceId);
}
/**
* @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);
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);
return playlists == null || playlists.getItems() == null ? Collections.emptyList() : playlists.getItems();
}
/**
* @return Calls Spotify Api and returns the current playing context of the user or an empty object if no context as
* returned by Spotify
*/
public CurrentlyPlayingContext getPlayerInfo() {
final ContentResponse response = requestPlayer(GET, "");
final CurrentlyPlayingContext context = ModelUtil.gsonInstance().fromJson(response.getContentAsString(),
CurrentlyPlayingContext.class);
return context == null ? EMPTY_CURRENTLYPLAYINGCONTEXT : context;
}
/**
* Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
* Spotify.
*
* @param method Http method to perform
* @param url url path to call to spotify
* @return the response give by Spotify
*/
private ContentResponse requestPlayer(HttpMethod method, String url) {
return requestPlayer(method, url, "");
}
/**
* Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
* Spotify.
*
* @param method Http method to perform
* @param url url path to call to spotify
* @param requestData data to pass along with the call as content
* @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);
}
/**
* 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 requestData data to pass along with the call as content
* @return the response give by Spotify
*/
private ContentResponse request(HttpMethod method, String url, String requestData) {
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);
try {
final AccessTokenResponse accessTokenResponse = oAuthClientService.getAccessTokenResponse();
final String accessToken = accessTokenResponse == null ? null : accessTokenResponse.getAccessToken();
if (accessToken == null || accessToken.isEmpty()) {
throw new SpotifyAuthorizationException(
"No spotify accesstoken. Did you authorize spotify via /connectspotify ?");
} else {
return requestWithRetry(call, accessToken);
}
} catch (IOException e) {
throw new SpotifyException(e.getMessage(), e);
} catch (OAuthException | OAuthResponseException e) {
throw new SpotifyAuthorizationException(e.getMessage(), e);
}
}
private ContentResponse requestWithRetry(final Function<HttpClient, Request> call, final String accessToken)
throws OAuthException, IOException, OAuthResponseException {
try {
return connector.request(call, BEARER + accessToken);
} catch (SpotifyTokenExpiredException e) {
// Retry with new access token
return connector.request(call, BEARER + oAuthClientService.refreshToken().getAccessToken());
}
}
}

View File

@@ -0,0 +1,261 @@
/**
* Copyright (c) 2010-2020 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.api;
import static org.eclipse.jetty.http.HttpStatus.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Function;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.openhab.binding.spotify.internal.api.exception.SpotifyAuthorizationException;
import org.openhab.binding.spotify.internal.api.exception.SpotifyException;
import org.openhab.binding.spotify.internal.api.exception.SpotifyTokenExpiredException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.JsonSyntaxException;
/**
* Class to perform the actual call to the Spotify Api, interprets the returned Http status codes, and handles the error
* codes returned by the Spotify Web Api.
*
* @author Hilbrand Bouwkamp - Initial contribution
*/
@NonNullByDefault
class SpotifyConnector {
private static final String RETRY_AFTER_HEADER = "Retry-After";
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final int HTTP_CLIENT_TIMEOUT_SECONDS = 10;
private static final int HTTP_CLIENT_RETRY_COUNT = 5;
private final Logger logger = LoggerFactory.getLogger(SpotifyConnector.class);
private final JsonParser parser = new JsonParser();
private final HttpClient httpClient;
private final ScheduledExecutorService scheduler;
/**
* Constructor.
*
* @param scheduler Scheduler to reschedule calls when rate limit exceeded or call not ready
* @param httpClient http client to use to make http calls
*/
public SpotifyConnector(ScheduledExecutorService scheduler, HttpClient httpClient) {
this.scheduler = scheduler;
this.httpClient = httpClient;
}
/**
* Performs a call to the Spotify Web Api and returns the raw response. In there are problems this method can throw
* a Spotify exception.
*
* @param requester The function to construct the request with http client that is passed as argument to the
* function
* @param authorization The authorization string to use in the Authorization header
* @return the raw reponse given
*/
public ContentResponse request(Function<HttpClient, Request> requester, String authorization) {
final Caller caller = new Caller(requester, authorization);
try {
return caller.call().get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new SpotifyException("Thread interrupted");
} catch (ExecutionException e) {
final Throwable cause = e.getCause();
if (cause instanceof RuntimeException) {
throw (RuntimeException) cause;
} else {
throw new SpotifyException(e.getMessage(), e);
}
}
}
/**
* Class to handle a call to the Spotify Web Api. In case of rate limiting or not finished jobs it will retry in a
* specified time frame. It retries a number of times and then gives up with an exception.
*
* @author Hilbrand Bouwkamp - Initial contribution
*/
private class Caller {
private final Function<HttpClient, Request> requester;
private final String authorization;
private final CompletableFuture<ContentResponse> future = new CompletableFuture<>();
private int delaySeconds;
private int attempts;
/**
* Constructor.
*
* @param requester The function to construct the request with http client that is passed as argument to the
* function
* @param authorization The authorization string to use in the Authorization header
*/
public Caller(Function<HttpClient, Request> requester, String authorization) {
this.requester = requester;
this.authorization = authorization;
}
/**
* Performs the request as a Future. It will set the Future state once it's finished. This method will be
* scheduled again when the call is to be retried. The original caller should call the get method on the Future
* to wait for the call to finish. The first try is not scheduled so if it succeeds on the first call the get
* method directly returns the value.
*
* @return the Future holding the call
*/
public CompletableFuture<ContentResponse> call() {
attempts++;
try {
final boolean success = processResponse(
requester.apply(httpClient).header(AUTHORIZATION_HEADER, authorization)
.timeout(HTTP_CLIENT_TIMEOUT_SECONDS, TimeUnit.SECONDS).send());
if (!success) {
if (attempts < HTTP_CLIENT_RETRY_COUNT) {
logger.debug("Spotify Web API call attempt: {}", attempts);
scheduler.schedule(this::call, delaySeconds, TimeUnit.SECONDS);
} else {
logger.debug("Giving up on accessing Spotify Web API. Check network connectivity!");
future.completeExceptionally(new SpotifyException(
"Could not reach the Spotify Web Api after " + attempts + " retries."));
}
}
} catch (ExecutionException e) {
future.completeExceptionally(e.getCause());
} catch (RuntimeException | TimeoutException e) {
future.completeExceptionally(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
future.completeExceptionally(e);
}
return future;
}
/**
* Processes the response of the Spotify Web Api call and handles the HTTP status codes. The method returns true
* if the response indicates a successful and false if the call should be retried. If there were other problems
* a Spotify exception is thrown indicating no retry should be done an the user should be informed.
*
* @param response the response given by the Spotify Web Api
* @return true if the response indicated a successful call, false if the call should be retried
*/
private boolean processResponse(ContentResponse response) {
boolean success = false;
logger.debug("Response Code: {}", response.getStatus());
if (logger.isTraceEnabled()) {
logger.trace("Response Data: {}", response.getContentAsString());
}
switch (response.getStatus()) {
case OK_200:
case CREATED_201:
case NO_CONTENT_204:
case NOT_MODIFIED_304:
future.complete(response);
success = true;
break;
case ACCEPTED_202:
logger.debug(
"Spotify Web API returned code 202 - The request has been accepted for processing, but the processing has not been completed.");
future.complete(response);
success = true;
break;
case BAD_REQUEST_400:
throw new SpotifyException(processErrorState(response));
case UNAUTHORIZED_401:
throw new SpotifyAuthorizationException(processErrorState(response));
case TOO_MANY_REQUESTS_429:
// Response Code 429 means requests rate limits exceeded.
final String retryAfter = response.getHeaders().get(RETRY_AFTER_HEADER);
logger.debug(
"Spotify Web API returned code 429 (rate limit exceeded). Retry After {} seconds. Decrease polling interval of bridge! Going to sleep...",
retryAfter);
delaySeconds = Integer.parseInt(retryAfter);
break;
case FORBIDDEN_403:
// Process for authorization error, and logging.
processErrorState(response);
future.complete(response);
success = true;
break;
case NOT_FOUND_404:
throw new SpotifyException(processErrorState(response));
case SERVICE_UNAVAILABLE_503:
case INTERNAL_SERVER_ERROR_500:
case BAD_GATEWAY_502:
default:
throw new SpotifyException("Spotify returned with error status: " + response.getStatus());
}
return success;
}
/**
* Processes the responded content if the status code indicated an error. If the response could be parsed the
* content error message is returned. If the error indicated a token or authorization error a specific exception
* is thrown. If an error message is thrown the caller throws the appropriate exception based on the state with
* which the error was returned by the Spotify Web Api.
*
* @param response content returned by Spotify Web Api
* @return the error messages
*/
private String processErrorState(ContentResponse response) {
try {
final JsonElement element = parser.parse(response.getContentAsString());
if (element.isJsonObject()) {
final JsonObject object = element.getAsJsonObject();
if (object.has("error") && object.get("error").isJsonObject()) {
final String message = object.get("error").getAsJsonObject().get("message").getAsString();
// Bad request can be anything, from authorization problems to start play problems.
// Therefore authorization type errors are filtered and handled differently.
logger.debug("Bad request: {}", message);
if (message.contains("expired")) {
throw new SpotifyTokenExpiredException(message);
} else {
return message;
}
} else if (object.has("error_description")) {
final String errorDescription = object.get("error_description").getAsString();
throw new SpotifyAuthorizationException(errorDescription);
}
}
logger.debug("Unknown response: {}", response);
return "Unknown response";
} catch (JsonSyntaxException e) {
logger.debug("Response was not json: ", e);
return "Unknown response";
}
}
}
}

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2010-2020 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.api.exception;
/**
* Spotify authorization problems exception class.
*
* @author Hilbrand Bouwkamp - Initial contribution
*/
public class SpotifyAuthorizationException extends RuntimeException {
private static final long serialVersionUID = -1931713564920750911L;
/**
* Constructor.
*
* @param message Spotify error message
*/
public SpotifyAuthorizationException(String message) {
super(message);
}
/**
* Constructor.
*
* @param message Spotify error message
* @param exception Original cause of this exception
*/
public SpotifyAuthorizationException(String message, Throwable exception) {
super(message, exception);
}
}

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2010-2020 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.api.exception;
/**
* Generic Spotify exception class.
*
* @author Hilbrand Bouwkamp - Initial contribution
*/
public class SpotifyException extends RuntimeException {
private static final long serialVersionUID = -8142837343923954830L;
/**
* Constructor.
*
* @param message Spotify error message
*/
public SpotifyException(String message) {
super(message);
}
/**
* Constructor.
*
* @param message Spotify error message
* @param cause Original cause of this exception
*/
public SpotifyException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) 2010-2020 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.api.exception;
/**
* Spotify exception indicating the access token has expired.
*
* @author Hilbrand Bouwkamp - Initial contribution
*/
public class SpotifyTokenExpiredException extends SpotifyAuthorizationException {
private static final long serialVersionUID = 709275673779738436L;
/**
* Constructor
*
* @param message Spotify error message
*/
public SpotifyTokenExpiredException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,75 @@
/**
* Copyright (c) 2010-2020 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.api.model;
import java.util.List;
/**
* Spotify Api Album data class.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Moved to it's own class
*/
public class Album {
private String albumType;
private List<Artist> artists;
private List<String> availableMarkets;
private ExternalUrl externalUrls;
private String href;
private String id;
private List<Image> images;
private String name;
private String type;
private String uri;
public String getAlbumType() {
return albumType;
}
public List<Artist> getArtists() {
return artists;
}
public List<String> getAvailableMarkets() {
return availableMarkets;
}
public ExternalUrl getExternalUrls() {
return externalUrls;
}
public String getHref() {
return href;
}
public String getId() {
return id;
}
public List<Image> getImages() {
return images;
}
public String getName() {
return name;
}
public String getType() {
return type;
}
public String getUri() {
return uri;
}
}

View File

@@ -0,0 +1,53 @@
/**
* Copyright (c) 2010-2020 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.api.model;
/**
* Spotify Api Artist data class.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Moved to it's own class
*/
public class Artist {
private ExternalUrl externalUrls;
private String href;
private String id;
private String name;
private String type;
private String uri;
public ExternalUrl getExternalUrls() {
return externalUrls;
}
public String getHref() {
return href;
}
public String getId() {
return id;
}
public String getName() {
return name;
}
public String getType() {
return type;
}
public String getUri() {
return uri;
}
}

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2010-2020 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.api.model;
/**
* Spotify Web Api Context data class.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Moved to it's own class
*/
public class Context {
private String type;
private String href;
private ExternalUrl externalUrls;
private String uri;
public String getType() {
return type;
}
public String getHref() {
return href;
}
public ExternalUrl getExternalUrls() {
return externalUrls;
}
public String getUri() {
return uri;
}
}

View File

@@ -0,0 +1,66 @@
/**
* Copyright (c) 2010-2020 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.api.model;
import com.google.gson.annotations.SerializedName;
/**
* Spotify Web API Currently Playing data class.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Moved to it's own class
*/
public class CurrentlyPlayingContext {
private long timestamp;
private long progressMs;
@SerializedName("is_playing")
private boolean playing;
private Item item;
private Context context;
private Device device;
private String repeatState;
private boolean shuffleState;
public long getTimestamp() {
return timestamp;
}
public long getProgressMs() {
return progressMs;
}
public boolean isPlaying() {
return playing;
}
public Item getItem() {
return item;
}
public Context getContext() {
return context;
}
public Device getDevice() {
return device;
}
public String getRepeatState() {
return repeatState;
}
public boolean isShuffleState() {
return shuffleState;
}
}

View File

@@ -0,0 +1,57 @@
/**
* Copyright (c) 2010-2020 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.api.model;
import com.google.gson.annotations.SerializedName;
/**
* Spotify Web Api Device data class.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Moved to it's own class
*/
public class Device {
private String id;
@SerializedName("is_active")
private boolean active;
@SerializedName("is_restricted")
private boolean restricted;
private String name;
private String type;
private Integer volumePercent;
public String getId() {
return id;
}
public boolean isActive() {
return active;
}
public boolean isRestricted() {
return restricted;
}
public String getName() {
return name;
}
public String getType() {
return type;
}
public Integer getVolumePercent() {
return volumePercent;
}
}

View File

@@ -0,0 +1,30 @@
/**
* Copyright (c) 2010-2020 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.api.model;
import java.util.List;
/**
* Spotify Web Api Devices data class.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Moved to it's own class
*/
public class Devices {
private List<Device> devices;
public List<Device> getDevices() {
return devices;
}
}

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) 2010-2020 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.api.model;
/**
* Spotify Web Api External Ids data class.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Moved to it's own class
*/
public class ExternalIds {
private String isrc;
public String getIsrc() {
return isrc;
}
public void setIsrc(String isrc) {
this.isrc = isrc;
}
}

View File

@@ -0,0 +1,30 @@
/**
* Copyright (c) 2010-2020 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.api.model;
import java.util.Map;
/**
* Spotify Web Api Url data class.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Moved to it's own class
*/
public class ExternalUrl {
private Map<String, String> externalUrls;
public Map<String, String> getExternalUrls() {
return externalUrls;
}
}

View File

@@ -0,0 +1,38 @@
/**
* Copyright (c) 2010-2020 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.api.model;
/**
* Spotify Web Api Image data class.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Moved to it's own class
*/
public class Image {
private Integer height;
private String url;
private Integer width;
public Integer getHeight() {
return height;
}
public String getUrl() {
return url;
}
public Integer getWidth() {
return width;
}
}

View File

@@ -0,0 +1,105 @@
/**
* Copyright (c) 2010-2020 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.api.model;
import java.util.List;
/**
* Spotify Web Api Device data class.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Moved to it's own class
*/
public class Item {
private Album album;
private List<Artist> artists;
private List<String> availableMarkets;
private Integer discNumber;
private long durationMs;
private boolean explicit;
private ExternalIds externalIds;
private ExternalUrl externalUrls;
private String href;
private String id;
private String name;
private Integer popularity;
private String previewUrl;
private Integer trackNumber;
private String type;
private String uri;
public Album getAlbum() {
return album;
}
public List<Artist> getArtists() {
return artists;
}
public List<String> getAvailableMarkets() {
return availableMarkets;
}
public Integer getDiscNumber() {
return discNumber;
}
public long getDurationMs() {
return durationMs;
}
public boolean isExplicit() {
return explicit;
}
public ExternalIds getExternalIds() {
return externalIds;
}
public ExternalUrl getExternalUrls() {
return externalUrls;
}
public String getHref() {
return href;
}
public String getId() {
return id;
}
public String getName() {
return name;
}
public Integer getPopularity() {
return popularity;
}
public String getPreviewUrl() {
return previewUrl;
}
public Integer getTrackNumber() {
return trackNumber;
}
public String getType() {
return type;
}
public String getUri() {
return uri;
}
}

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2010-2020 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.api.model;
/**
* Spotify Web Api user data class.
*
* @author Hilbrand Bouwkamp - Initial contribution
*/
public class Me {
private String displayName;
private String id;
private String product;
public String getDisplayName() {
return displayName;
}
public String getId() {
return id;
}
public String getProduct() {
return product;
}
}

View File

@@ -0,0 +1,38 @@
/**
* Copyright (c) 2010-2020 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.api.model;
import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
/**
* Util class to get the Gson instance used to parse the Spotify data.
*
* @author Hilbrand Bouwkamp - Initial contribution
*/
public final class ModelUtil {
private static final Gson GSON = new GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
private ModelUtil() {
// Util class
}
/**
* @return Returns the Gson instance to parse the Spotify data.
*/
public static Gson gsonInstance() {
return GSON;
}
}

View File

@@ -0,0 +1,28 @@
/**
* Copyright (c) 2010-2020 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.api.model;
import java.util.List;
/**
* Spotify Web Api generic paging object class.
*
* @author Hilbrand Bouwkamp - Initial contribution
*/
public class Paging<T> {
List<T> items;
public List<T> getItems() {
return items;
}
}

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) 2010-2020 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.api.model;
/**
* Spotify Web Api Playlist data class.
*
* @author Hilbrand Bouwkamp - Initial contribution
*/
public class Playlist {
private String name;
private String uri;
public String getName() {
return name;
}
public String getUri() {
return uri;
}
}

View File

@@ -0,0 +1,21 @@
/**
* Copyright (c) 2010-2020 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.api.model;
/**
* Spotify Web Api Playlists data class.
*
* @author Hilbrand Bouwkamp - Initial contribution
*/
public class Playlists extends Paging<Playlist> {
}

View File

@@ -0,0 +1,142 @@
/**
* Copyright (c) 2010-2020 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.discovery;
import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.*;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.spotify.internal.SpotifyAccountHandler;
import org.openhab.binding.spotify.internal.SpotifyBindingConstants;
import org.openhab.binding.spotify.internal.api.model.Device;
import org.openhab.core.config.discovery.AbstractDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link SpotifyDeviceDiscoveryService} queries the Spotify Web API for available devices.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Simplfied code to make call to shared code
*/
@NonNullByDefault
public class SpotifyDeviceDiscoveryService extends AbstractDiscoveryService
implements DiscoveryService, ThingHandlerService {
// id for device is derived by stripping id of device with this length
private static final int PLAYER_ID_LENGTH = 4;
// Only devices can be discovered. A bridge must be manually added.
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_DEVICE);
// The call to listDevices is fast
private static final int DISCOVERY_TIME_SECONDS = 10;
// Check every minute for new devices
private static final long BACKGROUND_SCAN_REFRESH_MINUTES = 1;
private final Logger logger = LoggerFactory.getLogger(SpotifyDeviceDiscoveryService.class);
private @NonNullByDefault({}) SpotifyAccountHandler bridgeHandler;
private @NonNullByDefault({}) ThingUID bridgeUID;
private @Nullable ScheduledFuture<?> backgroundFuture;
public SpotifyDeviceDiscoveryService() {
super(SUPPORTED_THING_TYPES_UIDS, DISCOVERY_TIME_SECONDS);
}
@Override
public Set<ThingTypeUID> getSupportedThingTypes() {
return SUPPORTED_THING_TYPES_UIDS;
}
@Override
public void activate() {
Map<String, @Nullable Object> properties = new HashMap<>();
properties.put(DiscoveryService.CONFIG_PROPERTY_BACKGROUND_DISCOVERY, Boolean.TRUE);
super.activate(properties);
}
@Override
public void deactivate() {
super.deactivate();
}
@Override
public void setThingHandler(@Nullable ThingHandler handler) {
if (handler instanceof SpotifyAccountHandler) {
bridgeHandler = (SpotifyAccountHandler) handler;
bridgeUID = bridgeHandler.getUID();
}
}
@Override
public @Nullable ThingHandler getThingHandler() {
return bridgeHandler;
}
@Override
protected synchronized void startBackgroundDiscovery() {
stopBackgroundDiscovery();
backgroundFuture = scheduler.scheduleWithFixedDelay(this::startScan, BACKGROUND_SCAN_REFRESH_MINUTES,
BACKGROUND_SCAN_REFRESH_MINUTES, TimeUnit.MINUTES);
}
@Override
protected synchronized void stopBackgroundDiscovery() {
if (backgroundFuture != null) {
backgroundFuture.cancel(true);
backgroundFuture = null;
}
}
@Override
protected void startScan() {
// If the bridge is not online no other thing devices can be found, so no reason to scan at this moment.
removeOlderResults(getTimestampOfLastScan());
if (bridgeHandler.isOnline()) {
logger.debug("Starting Spotify Device discovery for bridge {}", bridgeUID);
try {
bridgeHandler.listDevices().forEach(this::thingDiscovered);
} catch (RuntimeException e) {
logger.debug("Finding devices failed with message: {}", e.getMessage(), e);
}
}
}
private void thingDiscovered(Device device) {
Map<String, Object> properties = new HashMap<>();
properties.put(PROPERTY_SPOTIFY_DEVICE_NAME, device.getName());
ThingUID thing = new ThingUID(SpotifyBindingConstants.THING_TYPE_DEVICE, bridgeUID,
device.getId().substring(0, PLAYER_ID_LENGTH));
DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thing).withBridge(bridgeUID)
.withProperties(properties).withRepresentationProperty(PROPERTY_SPOTIFY_DEVICE_NAME)
.withLabel(device.getName()).build();
thingDiscovered(discoveryResult);
}
}

View File

@@ -0,0 +1,694 @@
/**
* Copyright (c) 2010-2020 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.handler;
import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.*;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.time.Duration;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
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.api.SpotifyApi;
import org.openhab.binding.spotify.internal.api.exception.SpotifyAuthorizationException;
import org.openhab.binding.spotify.internal.api.exception.SpotifyException;
import org.openhab.binding.spotify.internal.api.model.Album;
import org.openhab.binding.spotify.internal.api.model.Artist;
import org.openhab.binding.spotify.internal.api.model.Context;
import org.openhab.binding.spotify.internal.api.model.CurrentlyPlayingContext;
import org.openhab.binding.spotify.internal.api.model.Device;
import org.openhab.binding.spotify.internal.api.model.Image;
import org.openhab.binding.spotify.internal.api.model.Item;
import org.openhab.binding.spotify.internal.api.model.Me;
import org.openhab.binding.spotify.internal.api.model.Playlist;
import org.openhab.binding.spotify.internal.discovery.SpotifyDeviceDiscoveryService;
import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
import org.openhab.core.auth.client.oauth2.OAuthClientService;
import org.openhab.core.auth.client.oauth2.OAuthException;
import org.openhab.core.auth.client.oauth2.OAuthFactory;
import org.openhab.core.auth.client.oauth2.OAuthResponseException;
import org.openhab.core.cache.ExpiringCache;
import org.openhab.core.io.net.http.HttpUtil;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.PlayPauseType;
import org.openhab.core.library.types.RawType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link SpotifyBridgeHandler} is the main class to manage Spotify WebAPI connection and update status of things.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Just a lot of refactoring
*/
@NonNullByDefault
public class SpotifyBridgeHandler extends BaseBridgeHandler
implements SpotifyAccountHandler, AccessTokenRefreshListener {
private static final CurrentlyPlayingContext EMPTY_CURRENTLY_PLAYING_CONTEXT = new CurrentlyPlayingContext();
private static final Album EMPTY_ALBUM = new Album();
private static final Artist EMPTY_ARTIST = new Artist();
private static final Item EMPTY_ITEM = new Item();
private static final Device EMPTY_DEVICE = new Device();
private static final SimpleDateFormat MUSIC_TIME_FORMAT = new SimpleDateFormat("m:ss");
private static final int MAX_IMAGE_SIZE = 500000;
/**
* Only poll playlist once per hour (or when refresh is called).
*/
private static final Duration POLL_PLAY_LIST_HOURS = Duration.ofHours(1);
/**
* After a command is handles. With the given delay a status poll request is triggered. The delay is to give Spotify
* some time to handle the update.
*/
private static final int POLL_DELAY_AFTER_COMMAND_S = 2;
/**
* Time between track progress status updates.
*/
private static final int PROGRESS_STEP_S = 1;
private static final long PROGRESS_STEP_MS = TimeUnit.SECONDS.toMillis(PROGRESS_STEP_S);
private final Logger logger = LoggerFactory.getLogger(SpotifyBridgeHandler.class);
// Object to synchronize poll status on
private final Object pollSynchronization = new Object();
private final ProgressUpdater progressUpdater = new ProgressUpdater();
private final AlbumUpdater albumUpdater = new AlbumUpdater();
private final OAuthFactory oAuthFactory;
private final HttpClient httpClient;
private final SpotifyDynamicStateDescriptionProvider spotifyDynamicStateDescriptionProvider;
private final ChannelUID devicesChannelUID;
private final ChannelUID playlistsChannelUID;
// Field members assigned in initialize method
private @NonNullByDefault({}) Future<?> pollingFuture;
private @NonNullByDefault({}) OAuthClientService oAuthService;
private @NonNullByDefault({}) SpotifyApi spotifyApi;
private @NonNullByDefault({}) SpotifyBridgeConfiguration configuration;
private @NonNullByDefault({}) SpotifyHandleCommands handleCommand;
private @NonNullByDefault({}) ExpiringCache<CurrentlyPlayingContext> playingContextCache;
private @NonNullByDefault({}) ExpiringCache<List<Playlist>> playlistCache;
private @NonNullByDefault({}) ExpiringCache<List<Device>> devicesCache;
/**
* Keep track if this instance is disposed. This avoids new scheduling to be started after dispose is called.
*/
private volatile boolean active;
private volatile State lastTrackId = StringType.EMPTY;
private volatile String lastKnownDeviceId = "";
private volatile boolean lastKnownDeviceActive;
public SpotifyBridgeHandler(Bridge bridge, OAuthFactory oAuthFactory, HttpClient httpClient,
SpotifyDynamicStateDescriptionProvider spotifyDynamicStateDescriptionProvider) {
super(bridge);
this.oAuthFactory = oAuthFactory;
this.httpClient = httpClient;
this.spotifyDynamicStateDescriptionProvider = spotifyDynamicStateDescriptionProvider;
devicesChannelUID = new ChannelUID(bridge.getUID(), CHANNEL_DEVICES);
playlistsChannelUID = new ChannelUID(bridge.getUID(), CHANNEL_PLAYLISTS);
}
@Override
public Collection<Class<? extends ThingHandlerService>> getServices() {
return Collections.singleton(SpotifyDeviceDiscoveryService.class);
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof RefreshType) {
switch (channelUID.getId()) {
case CHANNEL_PLAYED_ALBUMIMAGE:
albumUpdater.refreshAlbumImage(channelUID);
break;
case CHANNEL_PLAYLISTS:
playlistCache.invalidateValue();
break;
case CHANNEL_ACCESSTOKEN:
onAccessTokenResponse(getAccessTokenResponse());
break;
default:
lastTrackId = StringType.EMPTY;
break;
}
} else {
try {
if (handleCommand != null
&& handleCommand.handleCommand(channelUID, command, lastKnownDeviceActive, lastKnownDeviceId)) {
scheduler.schedule(this::scheduledPollingRestart, POLL_DELAY_AFTER_COMMAND_S, TimeUnit.SECONDS);
}
} catch (SpotifyException e) {
logger.debug("Handle Spotify command failed: ", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
}
}
}
@Override
public void dispose() {
active = false;
if (oAuthService != null) {
oAuthService.removeAccessTokenRefreshListener(this);
}
oAuthFactory.ungetOAuthService(thing.getUID().getAsString());
cancelSchedulers();
}
@Override
public ThingUID getUID() {
return thing.getUID();
}
@Override
public String getLabel() {
return thing.getLabel() == null ? "" : thing.getLabel().toString();
}
@Override
public boolean isAuthorized() {
final AccessTokenResponse accessTokenResponse = getAccessTokenResponse();
return accessTokenResponse != null && accessTokenResponse.getAccessToken() != null
&& accessTokenResponse.getRefreshToken() != null;
}
private @Nullable AccessTokenResponse getAccessTokenResponse() {
try {
return oAuthService == null ? null : oAuthService.getAccessTokenResponse();
} catch (OAuthException | IOException | OAuthResponseException | RuntimeException e) {
logger.debug("Exception checking authorization: ", e);
return null;
}
}
@Override
public String getUser() {
return thing.getProperties().get(PROPERTY_SPOTIFY_USER);
}
@Override
public boolean isOnline() {
return thing.getStatus() == ThingStatus.ONLINE;
}
@Nullable
SpotifyApi getSpotifyApi() {
return spotifyApi;
}
@Override
public boolean equalsThingUID(String thingUID) {
return getThing().getUID().getAsString().equals(thingUID);
}
@Override
public String formatAuthorizationUrl(String redirectUri) {
try {
return oAuthService.getAuthorizationUrl(redirectUri, null, thing.getUID().getAsString());
} catch (OAuthException e) {
logger.debug("Error constructing AuthorizationUrl: ", e);
return "";
}
}
@Override
public String authorize(String redirectUri, String reqCode) {
try {
logger.debug("Make call to Spotify to get access token.");
final AccessTokenResponse credentials = oAuthService.getAccessTokenResponseByAuthorizationCode(reqCode,
redirectUri);
final String user = updateProperties(credentials);
logger.debug("Authorized for user: {}", user);
startPolling();
return user;
} catch (RuntimeException | OAuthException | IOException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
throw new SpotifyException(e.getMessage(), e);
} catch (OAuthResponseException e) {
throw new SpotifyAuthorizationException(e.getMessage(), e);
}
}
private String updateProperties(AccessTokenResponse credentials) {
if (spotifyApi != null) {
final Me me = spotifyApi.getMe();
final String user = me.getDisplayName() == null ? me.getId() : me.getDisplayName();
final Map<String, String> props = editProperties();
props.put(PROPERTY_SPOTIFY_USER, user);
updateProperties(props);
return user;
}
return "";
}
@Override
public void initialize() {
updateStatus(ThingStatus.UNKNOWN);
active = true;
configuration = getConfigAs(SpotifyBridgeConfiguration.class);
oAuthService = oAuthFactory.createOAuthClientService(thing.getUID().getAsString(), SPOTIFY_API_TOKEN_URL,
SPOTIFY_AUTHORIZE_URL, configuration.clientId, configuration.clientSecret, SPOTIFY_SCOPES, true);
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);
// Start with update status by calling Spotify. If no credentials available no polling should be started.
scheduler.execute(() -> {
if (pollStatus()) {
startPolling();
}
});
}
@Override
public List<Device> listDevices() {
final List<Device> listDevices = devicesCache.getValue();
return listDevices == null ? Collections.emptyList() : listDevices;
}
/**
* Scheduled method to restart polling in case polling is not running.
*/
private void scheduledPollingRestart() {
synchronized (pollSynchronization) {
try {
final boolean pollingNotRunning = pollingFuture == null || pollingFuture.isCancelled();
expireCache();
if (pollStatus() && pollingNotRunning) {
startPolling();
}
} catch (RuntimeException e) {
logger.debug("Restarting polling failed: ", e);
}
}
}
/**
* This method initiates a new thread for polling the available Spotify Connect devices and update the player
* information.
*/
private void startPolling() {
synchronized (pollSynchronization) {
cancelSchedulers();
if (active) {
expireCache();
pollingFuture = scheduler.scheduleWithFixedDelay(this::pollStatus, 0, configuration.refreshPeriod,
TimeUnit.SECONDS);
}
}
}
private void expireCache() {
playingContextCache.invalidateValue();
playlistCache.invalidateValue();
devicesCache.invalidateValue();
}
/**
* Calls the Spotify API and collects user data. Returns true if method completed without errors.
*
* @return true if method completed without errors.
*/
private boolean pollStatus() {
synchronized (pollSynchronization) {
try {
onAccessTokenResponse(getAccessTokenResponse());
// Collect currently playing context.
final CurrentlyPlayingContext pc = playingContextCache.getValue();
// If Spotify returned a 204. Meaning everything is ok, but we got no data.
// Happens when no song is playing. And we know no device was active
// No need to continue because no new information will be available.
final boolean hasPlayData = pc != null && pc.getDevice() != null;
final CurrentlyPlayingContext playingContext = pc == null ? EMPTY_CURRENTLY_PLAYING_CONTEXT : pc;
// Collect devices and populate selection with available devices.
if (hasPlayData || hasAnyDeviceStatusUnknown()) {
final List<Device> ld = devicesCache.getValue();
final List<Device> devices = ld == null ? Collections.emptyList() : ld;
spotifyDynamicStateDescriptionProvider.setDevices(devicesChannelUID, devices);
handleCommand.setDevices(devices);
updateDevicesStatus(devices, playingContext.isPlaying());
}
// Update play status information.
if (hasPlayData || getThing().getStatus() == ThingStatus.UNKNOWN) {
final List<Playlist> lp = playlistCache.getValue();
final List<Playlist> playlists = lp == null ? Collections.emptyList() : lp;
handleCommand.setPlaylists(playlists);
updatePlayerInfo(playingContext, playlists);
spotifyDynamicStateDescriptionProvider.setPlayLists(playlistsChannelUID, playlists);
}
updateStatus(ThingStatus.ONLINE);
return true;
} catch (SpotifyAuthorizationException e) {
logger.debug("Authorization error during polling: ", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
cancelSchedulers();
devicesCache.invalidateValue();
} catch (SpotifyException e) {
logger.info("Spotify returned an error during polling: {}", e.getMessage());
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
} catch (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);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
}
}
return false;
}
/**
* Cancels all running schedulers.
*/
private synchronized void cancelSchedulers() {
if (pollingFuture != null) {
pollingFuture.cancel(true);
}
progressUpdater.cancelProgressScheduler();
}
@Override
public void onAccessTokenResponse(@Nullable AccessTokenResponse tokenResponse) {
updateChannelState(CHANNEL_ACCESSTOKEN,
new StringType(tokenResponse == null ? null : tokenResponse.getAccessToken()));
}
/**
* Updates the status of all child Spotify Device Things.
*
* @param spotifyDevices list of Spotify devices
* @param playing true if the current active device is playing
*/
private void updateDevicesStatus(List<Device> spotifyDevices, boolean playing) {
getThing().getThings().stream() //
.filter(thing -> thing.getHandler() instanceof SpotifyDeviceHandler) //
.filter(thing -> !spotifyDevices.stream()
.anyMatch(sd -> ((SpotifyDeviceHandler) thing.getHandler()).updateDeviceStatus(sd, 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.
*
* @param playerInfo The object with the current playing context
* @param playlists List of available playlists
*/
private void updatePlayerInfo(CurrentlyPlayingContext playerInfo, List<Playlist> playlists) {
updateChannelState(CHANNEL_TRACKPLAYER, playerInfo.isPlaying() ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
updateChannelState(CHANNEL_DEVICESHUFFLE, OnOffType.from(playerInfo.isShuffleState()));
updateChannelState(CHANNEL_TRACKREPEAT, playerInfo.getRepeatState());
final boolean hasItem = playerInfo.getItem() != null;
final Item item = hasItem ? playerInfo.getItem() : EMPTY_ITEM;
final State trackId = valueOrEmpty(item.getId());
progressUpdater.updateProgress(active, playerInfo.isPlaying(), item.getDurationMs(),
playerInfo.getProgressMs());
if (!lastTrackId.equals(trackId)) {
lastTrackId = trackId;
updateChannelState(CHANNEL_PLAYED_TRACKDURATION_MS, new DecimalType(item.getDurationMs()));
final String formattedProgress;
synchronized (MUSIC_TIME_FORMAT) {
// synchronize because SimpleDateFormat is not thread safe
formattedProgress = MUSIC_TIME_FORMAT.format(new Date(item.getDurationMs()));
}
updateChannelState(CHANNEL_PLAYED_TRACKDURATION_FMT, formattedProgress);
updateChannelsPlayList(playerInfo, playlists);
updateChannelState(CHANNEL_PLAYED_TRACKID, lastTrackId);
updateChannelState(CHANNEL_PLAYED_TRACKHREF, valueOrEmpty(item.getHref()));
updateChannelState(CHANNEL_PLAYED_TRACKURI, valueOrEmpty(item.getUri()));
updateChannelState(CHANNEL_PLAYED_TRACKNAME, valueOrEmpty(item.getName()));
updateChannelState(CHANNEL_PLAYED_TRACKTYPE, valueOrEmpty(item.getType()));
updateChannelState(CHANNEL_PLAYED_TRACKNUMBER, valueOrZero(item.getTrackNumber()));
updateChannelState(CHANNEL_PLAYED_TRACKDISCNUMBER, valueOrZero(item.getDiscNumber()));
updateChannelState(CHANNEL_PLAYED_TRACKPOPULARITY, valueOrZero(item.getPopularity()));
updateChannelState(CHANNEL_PLAYED_TRACKEXPLICIT, OnOffType.from(item.isExplicit()));
final boolean hasAlbum = hasItem && item.getAlbum() != null;
final Album album = hasAlbum ? item.getAlbum() : EMPTY_ALBUM;
updateChannelState(CHANNEL_PLAYED_ALBUMID, valueOrEmpty(album.getId()));
updateChannelState(CHANNEL_PLAYED_ALBUMHREF, valueOrEmpty(album.getHref()));
updateChannelState(CHANNEL_PLAYED_ALBUMURI, valueOrEmpty(album.getUri()));
updateChannelState(CHANNEL_PLAYED_ALBUMNAME, valueOrEmpty(album.getName()));
updateChannelState(CHANNEL_PLAYED_ALBUMTYPE, valueOrEmpty(album.getType()));
albumUpdater.updateAlbumImage(album);
final Artist firstArtist = hasItem && item.getArtists() != null && !item.getArtists().isEmpty()
? item.getArtists().get(0)
: EMPTY_ARTIST;
updateChannelState(CHANNEL_PLAYED_ARTISTID, valueOrEmpty(firstArtist.getId()));
updateChannelState(CHANNEL_PLAYED_ARTISTHREF, valueOrEmpty(firstArtist.getHref()));
updateChannelState(CHANNEL_PLAYED_ARTISTURI, valueOrEmpty(firstArtist.getUri()));
updateChannelState(CHANNEL_PLAYED_ARTISTNAME, valueOrEmpty(firstArtist.getName()));
updateChannelState(CHANNEL_PLAYED_ARTISTTYPE, valueOrEmpty(firstArtist.getType()));
}
final Device device = playerInfo.getDevice() == null ? EMPTY_DEVICE : playerInfo.getDevice();
// Only update lastKnownDeviceId if it has a value, otherwise keep old value.
if (device.getId() != null) {
lastKnownDeviceId = device.getId();
updateChannelState(CHANNEL_DEVICEID, valueOrEmpty(lastKnownDeviceId));
updateChannelState(CHANNEL_DEVICES, valueOrEmpty(lastKnownDeviceId));
updateChannelState(CHANNEL_DEVICENAME, valueOrEmpty(device.getName()));
}
lastKnownDeviceActive = device.isActive();
updateChannelState(CHANNEL_DEVICEACTIVE, OnOffType.from(lastKnownDeviceActive));
updateChannelState(CHANNEL_DEVICETYPE, valueOrEmpty(device.getType()));
// experienced situations where volume seemed to be undefined...
updateChannelState(CHANNEL_DEVICEVOLUME,
device.getVolumePercent() == null ? UnDefType.UNDEF : new PercentType(device.getVolumePercent()));
}
private void updateChannelsPlayList(CurrentlyPlayingContext playerInfo, @Nullable List<Playlist> playlists) {
final Context context = playerInfo.getContext();
final String playlistId;
String playlistName = "";
if (context != null && "playlist".equals(context.getType())) {
playlistId = "spotify:playlist" + context.getUri().substring(context.getUri().lastIndexOf(':'));
if (playlists != null) {
final Optional<Playlist> optionalPlaylist = playlists.stream()
.filter(pl -> playlistId.equals(pl.getUri())).findFirst();
playlistName = optionalPlaylist.isPresent() ? optionalPlaylist.get().getName() : "";
}
} else {
playlistId = "";
}
updateChannelState(CHANNEL_PLAYLISTS, valueOrEmpty(playlistId));
updateChannelState(CHANNEL_PLAYLISTNAME, valueOrEmpty(playlistName));
}
/**
* @param value Integer value to return as {@link DecimalType}
* @return value as {@link DecimalType} or ZERO if the value is null
*/
private DecimalType valueOrZero(@Nullable Integer value) {
return value == null ? DecimalType.ZERO : new DecimalType(value);
}
/**
* @param value String value to return as {@link StringType}
* @return value as {@link StringType} or EMPTY if the value is null or empty
*/
private StringType valueOrEmpty(@Nullable String value) {
return value == null || value.isEmpty() ? StringType.EMPTY : new StringType(value);
}
/**
* Convenience method to update the channel state as {@link StringType} with a {@link String} value
*
* @param channelId id of the channel to update
* @param value String value to set as {@link StringType}
*/
private void updateChannelState(String channelId, String value) {
updateChannelState(channelId, new StringType(value));
}
/**
* Convenience method to update the channel state but only if the channel is linked.
*
* @param channelId id of the channel to update
* @param state State to set on the channel
*/
private void updateChannelState(String channelId, State state) {
final Channel channel = thing.getChannel(channelId);
if (channel != null && isLinked(channel.getUID())) {
updateState(channel.getUID(), state);
}
}
/**
* Class that manages the current progress of a track. The actual progress is tracked with the user specified
* interval, This class fills the in between seconds so the status will show a continues updating of the progress.
*
* @author Hilbrand Bouwkamp - Initial contribution
*/
private class ProgressUpdater {
private long progress;
private long duration;
private @NonNullByDefault({}) Future<?> progressFuture;
/**
* Updates the progress with its actual values as provided by Spotify. Based on if the track is running or not
* update the progress scheduler.
*
* @param active true if this instance is not disposed
* @param playing true if the track if playing
* @param duration duration of the track
* @param progress current progress of the track
*/
public synchronized void updateProgress(boolean active, boolean playing, long duration, long progress) {
this.duration = duration;
setProgress(progress);
if (!playing || !active) {
cancelProgressScheduler();
} else if ((progressFuture == null || progressFuture.isCancelled()) && active) {
progressFuture = scheduler.scheduleWithFixedDelay(this::incrementProgress, PROGRESS_STEP_S,
PROGRESS_STEP_S, TimeUnit.SECONDS);
}
}
/**
* Increments the progress with PROGRESS_STEP_MS, but limits it on the duration.
*/
private synchronized void incrementProgress() {
setProgress(Math.min(duration, progress + PROGRESS_STEP_MS));
}
/**
* Sets the progress on the channels.
*
* @param progress progress value to set
*/
private void setProgress(long progress) {
this.progress = progress;
final String formattedProgress;
synchronized (MUSIC_TIME_FORMAT) {
formattedProgress = MUSIC_TIME_FORMAT.format(new Date(progress));
}
updateChannelState(CHANNEL_PLAYED_TRACKPROGRESS_MS, new DecimalType(progress));
updateChannelState(CHANNEL_PLAYED_TRACKPROGRESS_FMT, formattedProgress);
}
/**
* Cancels the progress future.
*/
public synchronized void cancelProgressScheduler() {
if (progressFuture != null) {
progressFuture.cancel(true);
progressFuture = null;
}
}
}
/**
* Class to manager Album image updates.
*
* @author Hilbrand Bouwkamp - Initial contribution
*/
private class AlbumUpdater {
private String lastAlbumImageUrl = "";
/**
* Updates the album image status, but only refreshes the image when a new image should be shown.
*
* @param album album data
*/
public void updateAlbumImage(Album album) {
final Channel channel = 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();
if (!lastAlbumImageUrl.equals(imageUrl)) {
// Download the cover art in a different thread to not delay the other operations
lastAlbumImageUrl = imageUrl == null ? "" : imageUrl;
refreshAlbumImage(channel.getUID());
}
} else {
updateChannelState(CHANNEL_PLAYED_ALBUMIMAGE, UnDefType.UNDEF);
}
}
/**
* Refreshes the image asynchronously, but only downloads the image if the channel is linked to avoid
* unnecessary downloading of the image.
*
* @param channelUID UID of the album channel
*/
public void refreshAlbumImage(ChannelUID channelUID) {
if (!lastAlbumImageUrl.isEmpty() && isLinked(channelUID)) {
final String imageUrl = lastAlbumImageUrl;
scheduler.execute(() -> refreshAlbumAsynced(channelUID, imageUrl));
}
}
private void refreshAlbumAsynced(ChannelUID channelUID, String imageUrl) {
try {
if (lastAlbumImageUrl.equals(imageUrl) && isLinked(channelUID)) {
final RawType image = HttpUtil.downloadImage(imageUrl, true, MAX_IMAGE_SIZE);
updateChannelState(CHANNEL_PLAYED_ALBUMIMAGE, image == null ? UnDefType.UNDEF : image);
}
} catch (RuntimeException e) {
logger.debug("Async call to refresh Album image failed: ", e);
}
}
}
}

View File

@@ -0,0 +1,185 @@
/**
* Copyright (c) 2010-2020 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.handler;
import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.spotify.internal.api.SpotifyApi;
import org.openhab.binding.spotify.internal.api.exception.SpotifyException;
import org.openhab.binding.spotify.internal.api.model.Device;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.PlayPauseType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.ThingStatusInfo;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link SpotifyDeviceHandler} is responsible for handling commands, which are sent to one of the channels.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Code cleanup, moved channel state to this class, generic stability.
*/
@NonNullByDefault
public class SpotifyDeviceHandler extends BaseThingHandler {
private final Logger logger = LoggerFactory.getLogger(SpotifyDeviceHandler.class);
private @NonNullByDefault({}) SpotifyHandleCommands commandHandler;
private @NonNullByDefault({}) SpotifyApi spotifyApi;
private String deviceName = "";
private String deviceId = "";
private boolean active;
/**
* Constructor.
*
* @param thing Thing representing this device.
*/
public SpotifyDeviceHandler(Thing thing) {
super(thing);
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
try {
if (commandHandler != null && !deviceId.isEmpty()) {
commandHandler.handleCommand(channelUID, command, active, deviceId);
}
} catch (SpotifyException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
}
}
@Override
public void initialize() {
final SpotifyBridgeHandler bridgeHandler = (SpotifyBridgeHandler) getBridge().getHandler();
spotifyApi = bridgeHandler.getSpotifyApi();
if (spotifyApi == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String.format(
"Missing configuration from the Spotify Bridge (UID:%s). Fix configuration or report if this problem remains.",
getBridge().getBridgeUID()));
return;
}
deviceName = (String) getConfig().get(PROPERTY_SPOTIFY_DEVICE_NAME);
if (deviceName == null || deviceName.isEmpty()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"The deviceName property is not set or empty. If you have an older thing please recreate this thing.");
deviceName = "";
} else {
commandHandler = new SpotifyHandleCommands(spotifyApi);
updateStatus(ThingStatus.UNKNOWN);
}
}
@Override
public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
if (bridgeStatusInfo.getStatus() != ThingStatus.ONLINE) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "Spotify Bridge Offline");
logger.debug("SpotifyDevice {}: SpotifyBridge is not online: {}", getThing().getThingTypeUID(),
bridgeStatusInfo.getStatus());
}
}
/**
* Updates the status if the given device matches with this handler.
*
* @param device device with status information
* @param playing true if the current active device is playing
* @return returns true if given device matches with this handler
*/
public boolean updateDeviceStatus(Device device, boolean playing) {
if (deviceName.equals(device.getName())) {
deviceId = device.getId() == null ? "" : device.getId();
logger.debug("Updating status of Thing: {} Device [ {} {}, {} ]", thing.getUID(), deviceId,
device.getName(), device.getType());
final boolean online = setOnlineStatus(device.isRestricted());
updateChannelState(CHANNEL_DEVICEID, new StringType(deviceId));
updateChannelState(CHANNEL_DEVICENAME, new StringType(device.getName()));
updateChannelState(CHANNEL_DEVICETYPE, new StringType(device.getType()));
updateChannelState(CHANNEL_DEVICEVOLUME,
device.getVolumePercent() == null ? UnDefType.UNDEF : new PercentType(device.getVolumePercent()));
active = device.isActive();
updateChannelState(CHANNEL_DEVICEACTIVE, OnOffType.from(active));
updateChannelState(CHANNEL_DEVICEPLAYER,
online && active && playing ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
return true;
} else {
return false;
}
}
/**
* Updates the device as showing status is gone and reset all device status to default.
*/
public void setStatusGone() {
if (getThing().getStatus() != ThingStatus.OFFLINE
&& getThing().getStatusInfo().getStatusDetail() != ThingStatusDetail.GONE) {
logger.debug("Device is gone: {}", thing.getUID());
getThing().setStatusInfo(new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.GONE,
"Device not available on Spotify"));
updateChannelState(CHANNEL_DEVICERESTRICTED, OnOffType.ON);
updateChannelState(CHANNEL_DEVICEACTIVE, OnOffType.OFF);
updateChannelState(CHANNEL_DEVICEPLAYER, PlayPauseType.PAUSE);
}
}
/**
* Sets the device online status. If the device is restricted it will be set offline.
*
* @param restricted true if device is restricted (no access)
* @return true if device is online
*/
private boolean setOnlineStatus(boolean restricted) {
updateChannelState(CHANNEL_DEVICERESTRICTED, OnOffType.from(restricted));
final boolean statusUnknown = thing.getStatus() == ThingStatus.UNKNOWN;
if (restricted) {
// Only change status if device is currently online
if (thing.getStatus() == ThingStatus.ONLINE || statusUnknown) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
"Restricted. No Web API commands will be accepted by this device.");
}
return false;
} else if (statusUnknown || thing.getStatus() == ThingStatus.OFFLINE) {
updateStatus(ThingStatus.ONLINE);
}
return true;
}
/**
* Convenience method to update the channel state but only if the channel is linked.
*
* @param channelId id of the channel to update
* @param state State to set on the channel
*/
private void updateChannelState(String channelId, State state) {
final Channel channel = thing.getChannel(channelId);
if (channel != null && isLinked(channel.getUID())) {
updateState(channel.getUID(), state);
}
}
}

View File

@@ -0,0 +1,72 @@
/**
* Copyright (c) 2010-2020 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.handler;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.spotify.internal.api.model.Device;
import org.openhab.binding.spotify.internal.api.model.Playlist;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.binding.BaseDynamicStateDescriptionProvider;
import org.openhab.core.thing.type.DynamicStateDescriptionProvider;
import org.openhab.core.types.StateOption;
import org.osgi.service.component.annotations.Component;
/**
* Dynamically create the users list of devices and playlists.
*
* @author Hilbrand Bouwkamp - Initial contribution
*/
@Component(service = { DynamicStateDescriptionProvider.class, SpotifyDynamicStateDescriptionProvider.class })
@NonNullByDefault
public class SpotifyDynamicStateDescriptionProvider extends BaseDynamicStateDescriptionProvider {
private final Map<ChannelUID, List<Device>> devicesByChannel = new HashMap<>();
private final Map<ChannelUID, List<Playlist>> playlistsByChannel = new HashMap<>();
public void setDevices(ChannelUID channelUID, List<Device> spotifyDevices) {
final List<Device> devices = devicesByChannel.get(channelUID);
if (devices == null || (spotifyDevices.size() != devices.size()
|| !spotifyDevices.stream().allMatch(sd -> devices.stream().anyMatch(
d -> sd.getId() == d.getId() && d.getName() != null && d.getName().equals(sd.getName()))))) {
devicesByChannel.put(channelUID, spotifyDevices);
setStateOptions(channelUID, spotifyDevices.stream()
.map(device -> new StateOption(device.getId(), device.getName())).collect(Collectors.toList()));
}
}
public void setPlayLists(ChannelUID channelUID, List<Playlist> spotifyPlaylists) {
final List<Playlist> playlists = playlistsByChannel.get(channelUID);
if (playlists == null || (spotifyPlaylists.size() != playlists.size() || !spotifyPlaylists.stream()
.allMatch(sp -> playlists.stream().anyMatch(p -> p.getUri() != null && p.getUri().equals(sp.getUri())
&& p.getName() != null && p.getName().equals(sp.getName()))))) {
playlistsByChannel.put(channelUID, spotifyPlaylists);
setStateOptions(channelUID,
spotifyPlaylists.stream().map(playlist -> new StateOption(playlist.getUri(), playlist.getName()))
.collect(Collectors.toList()));
}
}
@Override
public void deactivate() {
super.deactivate();
devicesByChannel.clear();
playlistsByChannel.clear();
}
}

View File

@@ -0,0 +1,183 @@
/**
* Copyright (c) 2010-2020 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.handler;
import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.*;
import java.util.Collections;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.spotify.internal.api.SpotifyApi;
import org.openhab.binding.spotify.internal.api.model.Device;
import org.openhab.binding.spotify.internal.api.model.Playlist;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.NextPreviousType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.PlayPauseType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.types.Command;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Class to handle {@link Command} actions and call the Spotify Web Api.
*
* @author Andreas Stenlund - Initial contribution
* @author Hilbrand Bouwkamp - Moved to separate class, general refactoring and bug fixes
*/
@NonNullByDefault
class SpotifyHandleCommands {
private final Logger logger = LoggerFactory.getLogger(SpotifyHandleCommands.class);
private final SpotifyApi spotifyApi;
private List<Device> devices = Collections.emptyList();
private List<Playlist> playlists = Collections.emptyList();
/**
* Constructor. For the bridge the deviceId is empty.
*
* @param spotifyApi The api class to use to call the spotify api
*/
public SpotifyHandleCommands(SpotifyApi spotifyApi) {
this.spotifyApi = spotifyApi;
}
public void setDevices(final List<Device> devices) {
this.devices = devices;
}
public void setPlaylists(final List<Playlist> playlists) {
this.playlists = playlists;
}
/**
* Handles commands from the given Channel and calls Spotify Web Api with the given command.
*
* @param channelUID Channel the command is from
* @param command command to run
* @param active true if current known device is the active device
* @param deviceId Current known active Spotify device id
* @return true if the command was done or else if no channel or command matched
*/
public boolean handleCommand(ChannelUID channelUID, Command command, boolean active, String deviceId) {
logger.debug("Received channel: {}, command: {}", channelUID, command);
boolean commandRun = false;
final String channel = channelUID.getId();
switch (channel) {
case CHANNEL_DEVICENAME:
if (command instanceof StringType) {
final String newName = command.toString();
devices.stream().filter(d -> d.getName().equals(newName)).findFirst()
.ifPresent(d -> playDeviceId(d.getId(), active, deviceId));
commandRun = true;
}
break;
case CHANNEL_DEVICEID:
case CHANNEL_DEVICES:
if (command instanceof StringType) {
playDeviceId(command.toString(), active, deviceId);
commandRun = true;
}
break;
case CHANNEL_DEVICEPLAYER:
case CHANNEL_TRACKPLAYER:
commandRun = handleDevicePlay(command, active, deviceId);
break;
case CHANNEL_DEVICESHUFFLE:
if (command instanceof OnOffType) {
spotifyApi.setShuffleState(deviceId, (OnOffType) command);
commandRun = true;
}
break;
case CHANNEL_TRACKREPEAT:
if (command instanceof StringType) {
spotifyApi.setRepeatState(deviceId, command.toString());
commandRun = true;
}
case CHANNEL_DEVICEVOLUME:
if (command instanceof DecimalType) {
final PercentType volume = command instanceof PercentType ? (PercentType) command
: new PercentType(((DecimalType) command).intValue());
spotifyApi.setVolume(deviceId, volume.intValue());
commandRun = true;
}
break;
case CHANNEL_TRACKPLAY:
case CHANNEL_PLAYLISTS:
if (command instanceof StringType) {
spotifyApi.playTrack(deviceId, command.toString());
commandRun = true;
}
break;
case CHANNEL_PLAYLISTNAME:
if (command instanceof StringType) {
final String newName = command.toString();
playlists.stream().filter(pl -> pl.getName().equals(newName)).findFirst()
.ifPresent(pl -> spotifyApi.playTrack(deviceId, pl.getUri()));
commandRun = true;
}
break;
}
return commandRun;
}
private void playDeviceId(String newDeviceId, boolean active, String currentDeviceId) {
if (currentDeviceId.equals(newDeviceId) && active) {
spotifyApi.play(newDeviceId);
} else {
spotifyApi.transferPlay(newDeviceId, true);
}
}
/**
* Helper method to handle device play status.
*
* @param command command to run
* @param active true if the device this command is send to is the active device
* @param deviceId Spotify device id the command is intended for
* @return true if the command was done or else if no channel or command matched
*/
private boolean handleDevicePlay(Command command, boolean active, String deviceId) {
if (command instanceof PlayPauseType) {
final boolean play = command == PlayPauseType.PLAY;
if (active || deviceId.isEmpty()) {
if (play) {
spotifyApi.play(deviceId);
} else {
spotifyApi.pause(deviceId);
}
} else {
spotifyApi.transferPlay(deviceId, play);
}
return true;
} else if (command instanceof NextPreviousType) {
if (command == NextPreviousType.NEXT) {
spotifyApi.next(deviceId);
} else {
spotifyApi.previous(deviceId);
}
return true;
}
return false;
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<binding:binding id="spotify" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
<name>Spotify Binding</name>
<description>This is the openHAB binding for Spotify used to manage Connect devices. Using the Spotify WebAPI.</description>
<author>Andreas Stenlund, Hilbrand Bouwkamp</author>
</binding:binding>

View File

@@ -0,0 +1,361 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="spotify"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<!-- Spotify Player Device -->
<bridge-type id="player">
<label>Spotify Player Bridge</label>
<description>Bridge representing the Spotify Player in the context of a user account. The bridge is associated with
one specific Spotify account. If you want to control your devices in the context of different accounts you have to
register a bridge for each account. Go to http://your openHAB address::8080/connectspotify to authorize.</description>
<channels>
<channel id="accessToken" typeId="accessToken"/>
<channel id="deviceId" typeId="activeDeviceId"/>
<channel id="deviceName" typeId="activeDeviceName"/>
<channel id="devices" typeId="activeDevices"/>
<channel id="deviceType" typeId="activeDeviceType"/>
<channel id="deviceVolume" typeId="system.volume"/>
<channel id="deviceShuffle" typeId="activeDeviceShuffle"/>
<channel typeId="playlists" id="playlists"></channel>
<channel typeId="playlistName" id="playlistName"></channel>
<channel id="trackPlay" typeId="trackPlay"/>
<channel id="trackPlayer" typeId="system.media-control"/>
<channel id="trackRepeat" typeId="trackRepeat"/>
<channel id="trackId" typeId="currentlyPlayedTrackId"/>
<channel id="trackHref" typeId="currentlyPlayedTrackHref"/>
<channel id="trackUri" typeId="currentlyPlayedTrackUri"/>
<channel id="trackName" typeId="system.media-title"/>
<channel id="trackType" typeId="currentlyPlayedTrackType"/>
<channel id="trackNumber" typeId="currentlyPlayedTrackNumber"/>
<channel id="trackDiscNumber" typeId="currentlyPlayedTrackDiscNumber"/>
<channel id="trackPopularity" typeId="currentlyPlayedTrackPopularity"/>
<channel id="trackExplicit" typeId="currentlyPlayedTrackExplicit"/>
<channel id="trackDurationMs" typeId="currentlyPlayedTrackDurationMs"/>
<channel id="trackProgressMs" typeId="currentlyPlayedTrackProgressMs"/>
<channel id="trackDuration" typeId="currentlyPlayedTrackDuration"/>
<channel id="trackProgress" typeId="currentlyPlayedTrackProgress"/>
<channel id="albumId" typeId="currentlyPlayedAlbumId"/>
<channel id="albumUri" typeId="currentlyPlayedAlbumUri"/>
<channel id="albumHref" typeId="currentlyPlayedAlbumHref"/>
<channel id="albumType" typeId="currentlyPlayedAlbumType"/>
<channel id="albumImage" typeId="currentlyPlayedAlbumImage"/>
<channel id="albumName" typeId="currentlyPlayedAlbumName"/>
<channel id="artistId" typeId="currentlyPlayedArtistId"/>
<channel id="artistUri" typeId="currentlyPlayedArtistUri"/>
<channel id="artistHref" typeId="currentlyPlayedArtistHref"/>
<channel id="artistType" typeId="currentlyPlayedArtistType"/>
<channel id="artistName" typeId="system.media-artist"/>
</channels>
<properties>
<property name="user"/>
</properties>
<representation-property>clientId</representation-property>
<config-description>
<parameter name="clientId" type="text">
<required>true</required>
<label>Application Client ID</label>
<description>This is the Client ID provided by Spotify when you add a new Application for openHAB to your Spotify
Account. Go to https://developer.spotify.com/</description>
</parameter>
<parameter name="clientSecret" type="text">
<required>true</required>
<label>Application Client Secret</label>
<description>This is the Client Secret provided by Spotify when you add a new Application for openHAB to your
Spotify Account.</description>
</parameter>
<parameter name="refreshPeriod" type="integer" min="1" max="60">
<required>true</required>
<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>
</parameter>
</config-description>
</bridge-type>
<!-- Spotify Player Device -->
<thing-type id="device">
<supported-bridge-type-refs>
<bridge-type-ref id="player"/>
</supported-bridge-type-refs>
<label>Spotify Connect Device</label>
<description>Thing representing a Spotify Connect device. The device exists in the context of the bridge and the
Spotify account associated with the bride. This means the same device can be present as a thing under each Spotify
bridge you have configured.</description>
<channels>
<channel id="trackPlay" typeId="trackPlay"/>
<channel id="deviceId" typeId="deviceId"/>
<channel id="deviceName" typeId="deviceName"/>
<channel id="deviceType" typeId="deviceType"/>
<channel id="devicePlayer" typeId="system.media-control"/>
<channel id="deviceVolume" typeId="system.volume"/>
<channel id="deviceActive" typeId="deviceActive"/>
<channel id="deviceRestricted" typeId="deviceRestricted"/>
<channel id="deviceShuffle" typeId="deviceShuffle"/>
</channels>
<representation-property>deviceName</representation-property>
<config-description>
<parameter name="deviceName" type="text">
<required>true</required>
<label>Spotify Device Name</label>
<description>This is the device name provided by Spotify.</description>
</parameter>
</config-description>
</thing-type>
<!-- Channel Types -->
<channel-type id="accessToken" advanced="true">
<item-type>String</item-type>
<label>Access Token</label>
<description>The access token used to authorize calls to the Spotify Web API. It can be used from rules or client-side
scripting make call to Web API.</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="activeDeviceId" advanced="true">
<item-type>String</item-type>
<label>Active Device Id</label>
<description>The Spotify ID of active device</description>
</channel-type>
<channel-type id="activeDeviceName">
<item-type>String</item-type>
<label>Active Device Name</label>
<description>The name of the currently active device</description>
</channel-type>
<channel-type id="activeDevices">
<item-type>String</item-type>
<label>Active Devices</label>
<description>List of active devices.</description>
</channel-type>
<channel-type id="activeDeviceType" advanced="true">
<item-type>String</item-type>
<label>Active Device Type</label>
<description>Currently active device type, such as "Computer", "Smartphone" or "Speaker".</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="activeDeviceShuffle">
<item-type>Switch</item-type>
<label>Active Device Shuffle</label>
<description>If shuffle is on or off on the active device</description>
</channel-type>
<channel-type id="playlists">
<item-type>String</item-type>
<label>Playlists</label>
<description>List of the users playlists</description>
</channel-type>
<channel-type id="playlistName">
<item-type>String</item-type>
<label>Playlist Name</label>
<description>Name of the active playlist</description>
</channel-type>
<channel-type id="trackPlay" advanced="true">
<item-type>String</item-type>
<label>Track to Play</label>
<description>Assign track, album, or playlist to play. Accepts Spotify Uri's and links.</description>
</channel-type>
<channel-type id="trackRepeat">
<item-type>String</item-type>
<label>Repeat Mode</label>
<description>'track' will repeat the current track. 'context' will repeat the current context. 'off' will turn repeat
off.</description>
<state>
<options>
<option value="track">Track</option>
<option value="context">Context</option>
<option value="off">Off</option>
</options>
</state>
</channel-type>
<channel-type id="currentlyPlayedTrackId" advanced="true">
<item-type>String</item-type>
<label>Track Id</label>
<description>Channel reports track id currently being played.</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedTrackHref" advanced="true">
<item-type>String</item-type>
<label>Track URL</label>
<description></description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedTrackUri" advanced="true">
<item-type>String</item-type>
<label>Track URI</label>
<description>The Spotify URI for the track currently played</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedTrackType" advanced="true">
<item-type>String</item-type>
<label>Track Type</label>
<description>The type of the track currently played track type</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedTrackDurationMs" advanced="true">
<item-type>Number</item-type>
<label>Track Duration (ms)</label>
<description>The duration of the currently played track (ms)</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedTrackProgressMs" advanced="true">
<item-type>Number</item-type>
<label>Track Progress (ms)</label>
<description>The progress of the currently played track (ms)</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedTrackDuration">
<item-type>String</item-type>
<label>Track Duration (m:ss)</label>
<description>The duration currently played track formatted (m:ss)</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedTrackProgress">
<item-type>String</item-type>
<label>Track Progress (m:ss)</label>
<description>The progress of the currently played track formatted (m:ss)</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedTrackNumber" advanced="true">
<item-type>Number</item-type>
<label>Track Number</label>
<description>The track number of currently played track</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedTrackDiscNumber" advanced="true">
<item-type>Number</item-type>
<label>Track Disc Number</label>
<description>The disk number of currently played track</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedTrackPopularity" advanced="true">
<item-type>Number</item-type>
<label>Track Popularity</label>
<description>The popularity of the currently played track</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedTrackExplicit" advanced="true">
<item-type>Switch</item-type>
<label>Track Explicit</label>
<description>Whether or not the track has explicit lyrics (On it does; Off it does not OR unknown)</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedAlbumId" advanced="true">
<item-type>String</item-type>
<label>Album Id</label>
<description>The Spotify ID of the album</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedAlbumHref" advanced="true">
<item-type>String</item-type>
<label>Album URL</label>
<description>A link to the Web API endpoint providing full details of the album</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedAlbumUri" advanced="true">
<item-type>String</item-type>
<label>Album URI</label>
<description>The Spotify URI for the album</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedAlbumName">
<item-type>String</item-type>
<label>Album Name</label>
<description>The name of the album</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedAlbumType" advanced="true">
<item-type>String</item-type>
<label>Album Type</label>
<description>The object type: "album"</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedAlbumImage">
<item-type>Image</item-type>
<label>Album Image</label>
<description>The cover art for the album in widest size</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedArtistId" advanced="true">
<item-type>String</item-type>
<label>Artist Id</label>
<description>The Spotify ID for the artist</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedArtistHref" advanced="true">
<item-type>String</item-type>
<label>Artist URL</label>
<description>Channel reports currently played artist href</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedArtistUri" advanced="true">
<item-type>String</item-type>
<label>Artist URI</label>
<description>A link to the Web API endpoint providing full details of the artist</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="currentlyPlayedArtistType" advanced="true">
<item-type>String</item-type>
<label>Artist Type</label>
<description>The object type: "artist"</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="deviceId" advanced="true">
<item-type>String</item-type>
<label>Device Id</label>
<description>The Spotify ID of the device</description>
</channel-type>
<channel-type id="deviceName">
<item-type>String</item-type>
<label>Device Name</label>
<description>The name of the device</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="deviceType" advanced="true">
<item-type>String</item-type>
<label>Device Type</label>
<description>Device type, such as "Computer", "Smartphone" or "Speaker".</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="deviceShuffle">
<item-type>Switch</item-type>
<label>Device Shuffle</label>
<description>If shuffle is on or off</description>
</channel-type>
<channel-type id="deviceActive" advanced="true">
<item-type>Switch</item-type>
<label>Device Active</label>
<description>If this device is the currently active device</description>
</channel-type>
<channel-type id="deviceRestricted" advanced="true">
<item-type>Switch</item-type>
<label>Device Restricted</label>
<description>Whether controlling this device is restricted. At present if this is "true" then no Web API commands will
be accepted by this device</description>
</channel-type>
</thing:thing-descriptions>

View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
${pageRefresh}
<title>Authorize openHAB binding for Spotify</title>
<link rel="icon" href="img/favicon.ico" type="image/vnd.microsoft.icon">
<link>
<style>
html {
font-family: "Roboto", Helvetica, Arial, sans-serif;
}
.logo {
display: block;
margin: auto;
width: 100%;
}
.block {
border: 1px solid #bbb;
background-color: white;
margin: 10px 0;
padding: 8px 10px;
}
.error {
background: #FFC0C0;
border: 1px solid darkred;
color: darkred
}
.authorized {
border: 1px solid #90EE90;
background-color: #E0FFE0;
}
.button {
margin-bottom: 10px;
}
.button a {
background: #1ED760;
border-radius: 500px;
color: white;
padding: 10px 20px 10px;
font-size: 16px;
font-weight: 700;
border-width: 0;
text-decoration: none;
}
</style>
</head>
<body>
<svg class="logo" xmlns="http://www.w3.org/2000/svg" height="60px" version="1.1" viewBox="0 0 530 180">
<path style="stroke-width:0.625;stroke-linejoin:round;stroke:#000000;stroke-opacity:1;fill:#000000;fill-opacity:1;" d="m 195,90 l 15,10 c 0,0 -5,-5 0,-20 z m 10,3 l 120,0 l 0,-6 l -120,0 z m 130,-3 l -15,-10 c 0,0 5,5 0,20 z" />
<path style="fill:#1ed760;stroke-width:1.07472312"
d="M 439.99946,0 C 390.29519,0 350,40.295076 350,90.000539 350,139.70815 390.29519,180 439.99946,180 489.70911,180 530,139.70815 530,90.000539 530,40.2983 489.70911,0.004299 439.99839,0.004299 Z m 41.27318,129.80661 c -1.61207,2.64385 -5.07264,3.48214 -7.71642,1.85929 -21.13096,-12.90758 -47.73219,-15.83086 -79.05999,-8.67312 -3.01886,0.68783 -6.02805,-1.2037 -6.71586,-4.22371 -0.69104,-3.02001 1.19292,-6.02927 4.21931,-6.7171 34.28326,-7.83267 63.69055,-4.46015 87.41371,10.03803 2.64378,1.62285 3.48206,5.07276 1.85925,7.71661 z m 11.01578,-24.50613 c -2.03121,3.30159 -6.35154,4.34408 -9.6509,2.31283 -24.19173,-14.870044 -61.06826,-19.176511 -89.68242,-10.490495 -3.71098,1.120949 -7.63045,-0.970487 -8.75674,-4.675102 -1.1177,-3.711063 0.97476,-7.623102 4.67928,-8.751575 32.68517,-9.917663 73.31889,-5.113594 101.10015,11.958587 3.29936,2.031251 4.34183,6.351685 2.31063,9.646825 z m 0.94574,-25.51853 C 464.22773,62.552857 416.37088,60.968697 388.67667,69.374207 384.22952,70.723 379.52659,68.212417 378.1789,63.76516 c -1.34768,-4.449407 1.16069,-9.149228 5.61107,-10.501246 31.791,-9.651129 84.63988,-7.786462 118.03543,12.039193 4.00866,2.374092 5.31981,7.540347 2.9447,11.535141 -2.36436,4.000167 -7.54446,5.318868 -11.53164,2.943702 z" />
<g transform="matrix(1.0671711,0,0,1.0802403,-242.07004,-3.638968)">
<path d="m 235.55996,122.32139 65.99684,-65.245439 9.61127,-9.494429 9.61126,9.494429 48.71786,48.127399 -0.0713,0.24287 -0.96104,2.79606 -1.09061,2.7291 -1.22617,2.66469 -1.34976,2.59982 -1.22704,2.10395 -52.40328,-51.76861 c -22.86589,22.605906 -45.73092,45.21138 -68.59595,67.81602 -2.64028,-3.84168 -5.09265,-7.79865 -7.01216,-12.06586 z" style="clip-rule:evenodd;fill:#e64a19;fill-rule:evenodd;stroke-width:0.87332809" />
<path d="m 311.16893,3.3686968 c 46.45255,0 84.33469,37.4250352 84.33469,83.3154142 0,45.887409 -37.88214,83.314139 -84.33469,83.314139 -25.37318,0 -48.18501,-11.17665 -63.66633,-28.79057 l 2.98008,-2.95501 2.16319,-2.13785 2.16749,-2.14377 2.16835,-2.14209 0.0884,-0.0865 c 13.00665,15.21415 32.43854,24.9082 54.09884,24.9082 39.00964,0 70.82522,-31.42855 70.82522,-69.966579 0,-38.540154 -31.81558,-69.969554 -70.82522,-69.969554 -39.01137,0 -70.82694,31.4294 -70.82694,69.969554 0,5.189918 0.58434,10.24717 1.67968,15.121749 l -4.69923,4.65206 -6.27207,6.2012 c -2.73168,-8.18222 -4.217,-16.909931 -4.217,-25.97501 0,-45.89038 37.88214,-83.3154145 84.33556,-83.3154145 z" style="clip-rule:evenodd;fill:#474747;fill-rule:evenodd;stroke-width:0.87332809" />
</g>
</svg>
<h3>Authorize openHAB binding for Spotify</h3>
<p>On this page you can authorize your openHAB Spotify Player Bridge configured with the clientId and clientSecret of the Spotify Application on your Developer account, you have to login to your Spotify Account and authorize this binding to access your account.</p>
<p>To use this binding the following requirements apply:</p>
<ul>
<li>A Spotify Premium account.
<li>Register openHAB as an App on your Spotify Developer Dashboard.
</ul>
<p>
The redirect URI to use with Spotify for this openHAB installation is
<a href="${redirectUri}">${redirectUri}</a>
</p>
${error} ${authorizedUser} ${players}
</body>
</html>

View File

@@ -0,0 +1,4 @@
<div class="block${player.authorized}" id="${player.id}">
Connect to Spotify: <i>${player.name}${player.user}</i>
<p><div class="button"><a href=${player.authorize}>Authorize Player</a></div></p>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB