2 * Copyright (c) 2010-2021 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.Objects;
23 import java.util.concurrent.ScheduledExecutorService;
24 import java.util.function.Function;
25 import java.util.stream.Collectors;
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jetty.client.HttpClient;
29 import org.eclipse.jetty.client.api.ContentResponse;
30 import org.eclipse.jetty.client.api.Request;
31 import org.eclipse.jetty.client.util.StringContentProvider;
32 import org.eclipse.jetty.http.HttpMethod;
33 import org.openhab.binding.spotify.internal.api.exception.SpotifyAuthorizationException;
34 import org.openhab.binding.spotify.internal.api.exception.SpotifyException;
35 import org.openhab.binding.spotify.internal.api.exception.SpotifyTokenExpiredException;
36 import org.openhab.binding.spotify.internal.api.model.CurrentlyPlayingContext;
37 import org.openhab.binding.spotify.internal.api.model.Device;
38 import org.openhab.binding.spotify.internal.api.model.Devices;
39 import org.openhab.binding.spotify.internal.api.model.Me;
40 import org.openhab.binding.spotify.internal.api.model.ModelUtil;
41 import org.openhab.binding.spotify.internal.api.model.Playlist;
42 import org.openhab.binding.spotify.internal.api.model.Playlists;
43 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
44 import org.openhab.core.auth.client.oauth2.OAuthClientService;
45 import org.openhab.core.auth.client.oauth2.OAuthException;
46 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
47 import org.openhab.core.library.types.OnOffType;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
52 * Class to handle Spotify Web Api calls.
54 * @author Andreas Stenlund - Initial contribution
55 * @author Hilbrand Bouwkamp - Refactored calling Web Api and simplified code
58 public class SpotifyApi {
60 private static final String CONTENT_TYPE = "application/json";
61 private static final String BEARER = "Bearer ";
62 private static final char AMP = '&';
63 private static final char QSM = '?';
64 private static final CurrentlyPlayingContext EMPTY_CURRENTLYPLAYINGCONTEXT = new CurrentlyPlayingContext();
65 private static final String PLAY_TRACK_URIS = "{\"uris\":[%s],\"offset\":{\"position\":0}}";
66 private static final String PLAY_TRACK_CONTEXT_URI = "{\"context_uri\":\"%s\",\"offset\":{\"position\":0}}";
67 private static final String TRANSFER_PLAY = "{\"device_ids\":[\"%s\"],\"play\":%b}";
69 private final Logger logger = LoggerFactory.getLogger(SpotifyApi.class);
71 private final OAuthClientService oAuthClientService;
72 private final SpotifyConnector connector;
77 * @param authorizer The authorizer used to refresh the access token when expired
78 * @param connector The Spotify connector handling the Web Api calls to Spotify
80 public SpotifyApi(OAuthClientService oAuthClientService, ScheduledExecutorService scheduler,
81 HttpClient httpClient) {
82 this.oAuthClientService = oAuthClientService;
83 connector = new SpotifyConnector(scheduler, httpClient);
87 * @return Returns the Spotify user information
90 final ContentResponse response = request(GET, SPOTIFY_API_URL, "");
92 return Objects.requireNonNull(ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Me.class));
96 * Call Spotify Api to play the given track on the given device. If the device id is empty it will be played on
99 * @param deviceId device to play on or empty if play on the active device
100 * @param trackId id of the track to play
102 public void playTrack(String deviceId, String trackId) {
103 final String url = "play" + optionalDeviceId(deviceId, QSM);
105 if (trackId.contains(":track:")) {
106 play = String.format(PLAY_TRACK_URIS, Arrays.asList(trackId.split(",")).stream().map(t -> '"' + t + '"')
107 .collect(Collectors.joining(",")));
109 play = String.format(PLAY_TRACK_CONTEXT_URI, trackId);
111 requestPlayer(PUT, url, play);
115 * Call Spotify Api to start playing. If the device id is empty it will start play of the active device.
117 * @param deviceId device to play on or empty if play on the active device
119 public void play(String deviceId) {
120 requestPlayer(PUT, "play" + optionalDeviceId(deviceId, QSM));
124 * Call Spotify Api to transfer playing to. Depending on play value is start play or pause.
126 * @param deviceId device to play on. It can not be empty.
127 * @param play if true transfers and starts to play, else transfers but pauses.
129 public void transferPlay(String deviceId, boolean play) {
130 requestPlayer(PUT, "", String.format(TRANSFER_PLAY, deviceId, play));
134 * Call Spotify Api to pause playing. If the device id is empty it will pause play of the active device.
136 * @param deviceId device to pause on or empty if pause on the active device
138 public void pause(String deviceId) {
139 requestPlayer(PUT, "pause" + optionalDeviceId(deviceId, QSM));
143 * Call Spotify Api to play the next song. If the device id is empty it will play the next song on the active
146 * @param deviceId device to play next track on or empty if play next track on the active device
148 public void next(String deviceId) {
149 requestPlayer(POST, "next" + optionalDeviceId(deviceId, QSM));
153 * Call Spotify Api to play the previous song. If the device id is empty it will play the previous song on the
156 * @param deviceId device to play previous track on or empty if play previous track on the active device
158 public void previous(String deviceId) {
159 requestPlayer(POST, "previous" + optionalDeviceId(deviceId, QSM));
163 * Call Spotify Api to play set the volume. If the device id is empty it will set the volume on the active device.
165 * @param deviceId device to set the Volume on or empty if set volume on the active device
166 * @param volumePercent volume percentage value to set
168 public void setVolume(String deviceId, int volumePercent) {
169 requestPlayer(PUT, String.format("volume?volume_percent=%1d", volumePercent) + optionalDeviceId(deviceId, AMP));
173 * Call Spotify Api to play set the repeat state. If the device id is empty it will set the repeat state on the
176 * @param deviceId device to set repeat state on or empty if set repeat on the active device
177 * @param repeateState set the spotify repeat state
179 public void setRepeatState(String deviceId, String repeateState) {
180 requestPlayer(PUT, String.format("repeat?state=%s", repeateState) + optionalDeviceId(deviceId, AMP));
184 * Call Spotify Api to play set the shuffle. If the device id is empty it will set shuffle state on the active
187 * @param deviceId device to set shuffle state on or empty if set shuffle on the active device
188 * @param state the shuffle state to set
190 public void setShuffleState(String deviceId, OnOffType state) {
191 requestPlayer(PUT, String.format("shuffle?state=%s", state == OnOffType.OFF ? "false" : "true")
192 + optionalDeviceId(deviceId, AMP));
196 * Method to return an optional device id url pattern. If device id is empty an empty string is returned else the
197 * device id url query pattern prefixed with the given prefix char
199 * @param deviceId device to play on or empty if play on the active device
200 * @param prefix char to prefix to the deviceId string if present
201 * @return empty string or query string part for device id
203 private String optionalDeviceId(String deviceId, char prefix) {
204 return deviceId.isEmpty() ? "" : String.format("%cdevice_id=%s", prefix, deviceId);
208 * @return Calls Spotify Api and returns the list of device or an empty list if nothing was returned
210 public List<Device> getDevices() {
211 final ContentResponse response = requestPlayer(GET, "devices");
212 final Devices deviceList = ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Devices.class);
214 return deviceList == null || deviceList.getDevices() == null ? Collections.emptyList()
215 : deviceList.getDevices();
219 * @return Returns the playlists of the user.
221 public List<Playlist> getPlaylists() {
222 final ContentResponse response = request(GET, SPOTIFY_API_URL + "/playlists", "");
223 final Playlists playlists = ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Playlists.class);
225 return playlists == null || playlists.getItems() == null ? Collections.emptyList() : playlists.getItems();
229 * @return Calls Spotify Api and returns the current playing context of the user or an empty object if no context as
230 * returned by Spotify
232 public CurrentlyPlayingContext getPlayerInfo() {
233 final ContentResponse response = requestPlayer(GET, "");
234 final CurrentlyPlayingContext context = ModelUtil.gsonInstance().fromJson(response.getContentAsString(),
235 CurrentlyPlayingContext.class);
237 return context == null ? EMPTY_CURRENTLYPLAYINGCONTEXT : context;
241 * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
244 * @param method Http method to perform
245 * @param url url path to call to spotify
246 * @return the response give by Spotify
248 private ContentResponse requestPlayer(HttpMethod method, String url) {
249 return requestPlayer(method, url, "");
253 * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
256 * @param method Http method to perform
257 * @param url url path to call to spotify
258 * @param requestData data to pass along with the call as content
259 * @return the response give by Spotify
261 private ContentResponse requestPlayer(HttpMethod method, String url, String requestData) {
262 return request(method, SPOTIFY_API_PLAYER_URL + (url.isEmpty() ? "" : ('/' + url)), requestData);
266 * Calls the Spotify Web Api with the given method and given url as parameters of the call to Spotify.
268 * @param method Http method to perform
269 * @param url url path to call to spotify
270 * @param requestData data to pass along with the call as content
271 * @return the response give by Spotify
273 private ContentResponse request(HttpMethod method, String url, String requestData) {
274 logger.debug("Request: ({}) {} - {}", method, url, requestData);
275 final Function<HttpClient, Request> call = httpClient -> httpClient.newRequest(url).method(method)
276 .header("Accept", CONTENT_TYPE).content(new StringContentProvider(requestData), CONTENT_TYPE);
278 final AccessTokenResponse accessTokenResponse = oAuthClientService.getAccessTokenResponse();
279 final String accessToken = accessTokenResponse == null ? null : accessTokenResponse.getAccessToken();
281 if (accessToken == null || accessToken.isEmpty()) {
282 throw new SpotifyAuthorizationException(
283 "No spotify accesstoken. Did you authorize spotify via /connectspotify ?");
285 return requestWithRetry(call, accessToken);
287 } catch (IOException e) {
288 throw new SpotifyException(e.getMessage(), e);
289 } catch (OAuthException | OAuthResponseException e) {
290 throw new SpotifyAuthorizationException(e.getMessage(), e);
294 private ContentResponse requestWithRetry(final Function<HttpClient, Request> call, final String accessToken)
295 throws OAuthException, IOException, OAuthResponseException {
297 return connector.request(call, BEARER + accessToken);
298 } catch (SpotifyTokenExpiredException e) {
299 // Retry with new access token
300 return connector.request(call, BEARER + oAuthClientService.refreshToken().getAccessToken());