2 * Copyright (c) 2010-2020 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.spotify.internal.api;
15 import static org.eclipse.jetty.http.HttpMethod.*;
16 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.*;
18 import java.io.IOException;
19 import java.util.Arrays;
20 import java.util.Collections;
21 import java.util.List;
22 import java.util.concurrent.ScheduledExecutorService;
23 import java.util.function.Function;
24 import java.util.stream.Collectors;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jetty.client.HttpClient;
28 import org.eclipse.jetty.client.api.ContentResponse;
29 import org.eclipse.jetty.client.api.Request;
30 import org.eclipse.jetty.client.util.StringContentProvider;
31 import org.eclipse.jetty.http.HttpMethod;
32 import org.openhab.binding.spotify.internal.api.exception.SpotifyAuthorizationException;
33 import org.openhab.binding.spotify.internal.api.exception.SpotifyException;
34 import org.openhab.binding.spotify.internal.api.exception.SpotifyTokenExpiredException;
35 import org.openhab.binding.spotify.internal.api.model.CurrentlyPlayingContext;
36 import org.openhab.binding.spotify.internal.api.model.Device;
37 import org.openhab.binding.spotify.internal.api.model.Devices;
38 import org.openhab.binding.spotify.internal.api.model.Me;
39 import org.openhab.binding.spotify.internal.api.model.ModelUtil;
40 import org.openhab.binding.spotify.internal.api.model.Playlist;
41 import org.openhab.binding.spotify.internal.api.model.Playlists;
42 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
43 import org.openhab.core.auth.client.oauth2.OAuthClientService;
44 import org.openhab.core.auth.client.oauth2.OAuthException;
45 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
46 import org.openhab.core.library.types.OnOffType;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
51 * Class to handle Spotify Web Api calls.
53 * @author Andreas Stenlund - Initial contribution
54 * @author Hilbrand Bouwkamp - Refactored calling Web Api and simplified code
57 public class SpotifyApi {
59 private static final String CONTENT_TYPE = "application/json";
60 private static final String BEARER = "Bearer ";
61 private static final char AMP = '&';
62 private static final char QSM = '?';
63 private static final CurrentlyPlayingContext EMPTY_CURRENTLYPLAYINGCONTEXT = new CurrentlyPlayingContext();
64 private static final String PLAY_TRACK_URIS = "{\"uris\":[%s],\"offset\":{\"position\":0}}";
65 private static final String PLAY_TRACK_CONTEXT_URI = "{\"context_uri\":\"%s\",\"offset\":{\"position\":0}}";
66 private static final String TRANSFER_PLAY = "{\"device_ids\":[\"%s\"],\"play\":%b}";
68 private final Logger logger = LoggerFactory.getLogger(SpotifyApi.class);
70 private final OAuthClientService oAuthClientService;
71 private final SpotifyConnector connector;
76 * @param authorizer The authorizer used to refresh the access token when expired
77 * @param connector The Spotify connector handling the Web Api calls to Spotify
79 public SpotifyApi(OAuthClientService oAuthClientService, ScheduledExecutorService scheduler,
80 HttpClient httpClient) {
81 this.oAuthClientService = oAuthClientService;
82 connector = new SpotifyConnector(scheduler, httpClient);
86 * @return Returns the Spotify user information
89 final ContentResponse response = request(GET, SPOTIFY_API_URL, "");
91 return ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Me.class);
95 * Call Spotify Api to play the given track on the given device. If the device id is empty it will be played on
98 * @param deviceId device to play on or empty if play on the active device
99 * @param trackId id of the track to play
101 public void playTrack(String deviceId, String trackId) {
102 final String url = "play" + optionalDeviceId(deviceId, QSM);
104 if (trackId.contains(":track:")) {
105 play = String.format(PLAY_TRACK_URIS, Arrays.asList(trackId.split(",")).stream().map(t -> '"' + t + '"')
106 .collect(Collectors.joining(",")));
108 play = String.format(PLAY_TRACK_CONTEXT_URI, trackId);
110 requestPlayer(PUT, url, play);
114 * Call Spotify Api to start playing. If the device id is empty it will start play of the active device.
116 * @param deviceId device to play on or empty if play on the active device
118 public void play(String deviceId) {
119 requestPlayer(PUT, "play" + optionalDeviceId(deviceId, QSM));
123 * Call Spotify Api to transfer playing to. Depending on play value is start play or pause.
125 * @param deviceId device to play on. It can not be empty.
126 * @param play if true transfers and starts to play, else transfers but pauses.
128 public void transferPlay(String deviceId, boolean play) {
129 requestPlayer(PUT, "", String.format(TRANSFER_PLAY, deviceId, play));
133 * Call Spotify Api to pause playing. If the device id is empty it will pause play of the active device.
135 * @param deviceId device to pause on or empty if pause on the active device
137 public void pause(String deviceId) {
138 requestPlayer(PUT, "pause" + optionalDeviceId(deviceId, QSM));
142 * Call Spotify Api to play the next song. If the device id is empty it will play the next song on the active
145 * @param deviceId device to play next track on or empty if play next track on the active device
147 public void next(String deviceId) {
148 requestPlayer(POST, "next" + optionalDeviceId(deviceId, QSM));
152 * Call Spotify Api to play the previous song. If the device id is empty it will play the previous song on the
155 * @param deviceId device to play previous track on or empty if play previous track on the active device
157 public void previous(String deviceId) {
158 requestPlayer(POST, "previous" + optionalDeviceId(deviceId, QSM));
162 * Call Spotify Api to play set the volume. If the device id is empty it will set the volume on the active device.
164 * @param deviceId device to set the Volume on or empty if set volume on the active device
165 * @param volumePercent volume percentage value to set
167 public void setVolume(String deviceId, int volumePercent) {
168 requestPlayer(PUT, String.format("volume?volume_percent=%1d", volumePercent) + optionalDeviceId(deviceId, AMP));
172 * Call Spotify Api to play set the repeat state. If the device id is empty it will set the repeat state on the
175 * @param deviceId device to set repeat state on or empty if set repeat on the active device
176 * @param repeateState set the spotify repeat state
178 public void setRepeatState(String deviceId, String repeateState) {
179 requestPlayer(PUT, String.format("repeat?state=%s", repeateState) + optionalDeviceId(deviceId, AMP));
183 * Call Spotify Api to play set the shuffle. If the device id is empty it will set shuffle state on the active
186 * @param deviceId device to set shuffle state on or empty if set shuffle on the active device
187 * @param state the shuffle state to set
189 public void setShuffleState(String deviceId, OnOffType state) {
190 requestPlayer(PUT, String.format("shuffle?state=%s", state == OnOffType.OFF ? "false" : "true")
191 + optionalDeviceId(deviceId, AMP));
195 * Method to return an optional device id url pattern. If device id is empty an empty string is returned else the
196 * device id url query pattern prefixed with the given prefix char
198 * @param deviceId device to play on or empty if play on the active device
199 * @param prefix char to prefix to the deviceId string if present
200 * @return empty string or query string part for device id
202 private String optionalDeviceId(String deviceId, char prefix) {
203 return deviceId.isEmpty() ? "" : String.format("%cdevice_id=%s", prefix, deviceId);
207 * @return Calls Spotify Api and returns the list of device or an empty list if nothing was returned
209 public List<Device> getDevices() {
210 final ContentResponse response = requestPlayer(GET, "devices");
211 final Devices deviceList = ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Devices.class);
213 return deviceList == null || deviceList.getDevices() == null ? Collections.emptyList()
214 : deviceList.getDevices();
218 * @return Returns the playlists of the user.
220 public List<Playlist> getPlaylists() {
221 final ContentResponse response = request(GET, SPOTIFY_API_URL + "/playlists", "");
222 final Playlists playlists = ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Playlists.class);
224 return playlists == null || playlists.getItems() == null ? Collections.emptyList() : playlists.getItems();
228 * @return Calls Spotify Api and returns the current playing context of the user or an empty object if no context as
229 * returned by Spotify
231 public CurrentlyPlayingContext getPlayerInfo() {
232 final ContentResponse response = requestPlayer(GET, "");
233 final CurrentlyPlayingContext context = ModelUtil.gsonInstance().fromJson(response.getContentAsString(),
234 CurrentlyPlayingContext.class);
236 return context == null ? EMPTY_CURRENTLYPLAYINGCONTEXT : context;
240 * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
243 * @param method Http method to perform
244 * @param url url path to call to spotify
245 * @return the response give by Spotify
247 private ContentResponse requestPlayer(HttpMethod method, String url) {
248 return requestPlayer(method, url, "");
252 * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
255 * @param method Http method to perform
256 * @param url url path to call to spotify
257 * @param requestData data to pass along with the call as content
258 * @return the response give by Spotify
260 private ContentResponse requestPlayer(HttpMethod method, String url, String requestData) {
261 return request(method, SPOTIFY_API_PLAYER_URL + (url.isEmpty() ? "" : ('/' + url)), requestData);
265 * Calls the Spotify Web Api with the given method and given url as parameters of the call to Spotify.
267 * @param method Http method to perform
268 * @param url url path to call to spotify
269 * @param requestData data to pass along with the call as content
270 * @return the response give by Spotify
272 private ContentResponse request(HttpMethod method, String url, String requestData) {
273 logger.debug("Request: ({}) {} - {}", method, url, requestData);
274 final Function<HttpClient, Request> call = httpClient -> httpClient.newRequest(url).method(method)
275 .header("Accept", CONTENT_TYPE).content(new StringContentProvider(requestData), CONTENT_TYPE);
277 final AccessTokenResponse accessTokenResponse = oAuthClientService.getAccessTokenResponse();
278 final String accessToken = accessTokenResponse == null ? null : accessTokenResponse.getAccessToken();
280 if (accessToken == null || accessToken.isEmpty()) {
281 throw new SpotifyAuthorizationException(
282 "No spotify accesstoken. Did you authorize spotify via /connectspotify ?");
284 return requestWithRetry(call, accessToken);
286 } catch (IOException e) {
287 throw new SpotifyException(e.getMessage(), e);
288 } catch (OAuthException | OAuthResponseException e) {
289 throw new SpotifyAuthorizationException(e.getMessage(), e);
293 private ContentResponse requestWithRetry(final Function<HttpClient, Request> call, final String accessToken)
294 throws OAuthException, IOException, OAuthResponseException {
296 return connector.request(call, BEARER + accessToken);
297 } catch (SpotifyTokenExpiredException e) {
298 // Retry with new access token
299 return connector.request(call, BEARER + oAuthClientService.refreshToken().getAccessToken());