2 * Copyright (c) 2010-2023 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.GET;
16 import static org.eclipse.jetty.http.HttpMethod.POST;
17 import static org.eclipse.jetty.http.HttpMethod.PUT;
18 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.SPOTIFY_API_PLAYER_URL;
19 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.SPOTIFY_API_URL;
21 import java.io.IOException;
22 import java.util.Arrays;
23 import java.util.Collections;
24 import java.util.List;
25 import java.util.Objects;
26 import java.util.concurrent.ScheduledExecutorService;
27 import java.util.function.Function;
28 import java.util.stream.Collectors;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.eclipse.jetty.client.api.ContentResponse;
34 import org.eclipse.jetty.client.api.Request;
35 import org.eclipse.jetty.client.util.StringContentProvider;
36 import org.eclipse.jetty.http.HttpMethod;
37 import org.openhab.binding.spotify.internal.api.exception.SpotifyAuthorizationException;
38 import org.openhab.binding.spotify.internal.api.exception.SpotifyException;
39 import org.openhab.binding.spotify.internal.api.exception.SpotifyTokenExpiredException;
40 import org.openhab.binding.spotify.internal.api.model.CurrentlyPlayingContext;
41 import org.openhab.binding.spotify.internal.api.model.Device;
42 import org.openhab.binding.spotify.internal.api.model.Devices;
43 import org.openhab.binding.spotify.internal.api.model.Me;
44 import org.openhab.binding.spotify.internal.api.model.ModelUtil;
45 import org.openhab.binding.spotify.internal.api.model.Playlist;
46 import org.openhab.binding.spotify.internal.api.model.Playlists;
47 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
48 import org.openhab.core.auth.client.oauth2.OAuthClientService;
49 import org.openhab.core.auth.client.oauth2.OAuthException;
50 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
51 import org.openhab.core.library.types.OnOffType;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
55 import com.google.gson.JsonSyntaxException;
58 * Class to handle Spotify Web Api calls.
60 * @author Andreas Stenlund - Initial contribution
61 * @author Hilbrand Bouwkamp - Refactored calling Web Api and simplified code
64 public class SpotifyApi {
66 private static final String CONTENT_TYPE = "application/json";
67 private static final String BEARER = "Bearer ";
68 private static final char AMP = '&';
69 private static final char QSM = '?';
70 private static final CurrentlyPlayingContext EMPTY_CURRENTLYPLAYINGCONTEXT = new CurrentlyPlayingContext();
71 private static final String PLAY_TRACK_URIS = "{\"uris\":[%s],\"offset\":{\"position\":%d},\"position_ms\":%d}";
72 private static final String PLAY_TRACK_CONTEXT_URI = "{\"context_uri\":\"%s\",\"offset\":{\"position\":%d},\"position_ms\":%d}}";
73 private static final String TRANSFER_PLAY = "{\"device_ids\":[\"%s\"],\"play\":%b}";
75 private final Logger logger = LoggerFactory.getLogger(SpotifyApi.class);
77 private final OAuthClientService oAuthClientService;
78 private final SpotifyConnector connector;
83 * @param oAuthClientService The authorizer used to refresh the access token when expired
85 * @param httpClient The Spotify connector handling the Web Api calls to Spotify
87 public SpotifyApi(OAuthClientService oAuthClientService, ScheduledExecutorService scheduler,
88 HttpClient httpClient) {
89 this.oAuthClientService = oAuthClientService;
90 connector = new SpotifyConnector(scheduler, httpClient);
94 * @return Returns the Spotify user information
97 return Objects.requireNonNull(request(GET, SPOTIFY_API_URL, "", Me.class));
101 * Call Spotify Api to play the given track on the given device. If the device id is empty it will be played on
104 * @param deviceId device to play on or empty if play on the active device
105 * @param trackId id of the track to play
106 * @param offset offset
107 * @param positionMs position in ms
109 public void playTrack(String deviceId, String trackId, int offset, int positionMs) {
110 final String url = "play" + optionalDeviceId(deviceId, QSM);
112 if (trackId.contains(":track:")) {
113 play = String.format(PLAY_TRACK_URIS,
114 Arrays.asList(trackId.split(",")).stream().map(t -> '"' + t + '"').collect(Collectors.joining(",")),
117 play = String.format(PLAY_TRACK_CONTEXT_URI, trackId, offset, positionMs);
119 requestPlayer(PUT, url, play, String.class);
123 * Call Spotify Api to start playing. If the device id is empty it will start play of the active device.
125 * @param deviceId device to play on or empty if play on the active device
127 public void play(String deviceId) {
128 requestPlayer(PUT, "play" + optionalDeviceId(deviceId, QSM));
132 * Call Spotify Api to transfer playing to. Depending on play value is start play or pause.
134 * @param deviceId device to play on. It can not be empty.
135 * @param play if true transfers and starts to play, else transfers but pauses.
137 public void transferPlay(String deviceId, boolean play) {
138 requestPlayer(PUT, "", String.format(TRANSFER_PLAY, deviceId, play), String.class);
142 * Call Spotify Api to pause playing. If the device id is empty it will pause play of the active device.
144 * @param deviceId device to pause on or empty if pause on the active device
146 public void pause(String deviceId) {
147 requestPlayer(PUT, "pause" + optionalDeviceId(deviceId, QSM));
151 * Call Spotify Api to play the next song. If the device id is empty it will play the next song on the active
154 * @param deviceId device to play next track on or empty if play next track on the active device
156 public void next(String deviceId) {
157 requestPlayer(POST, "next" + optionalDeviceId(deviceId, QSM));
161 * Call Spotify Api to play the previous song. If the device id is empty it will play the previous song on the
164 * @param deviceId device to play previous track on or empty if play previous track on the active device
166 public void previous(String deviceId) {
167 requestPlayer(POST, "previous" + optionalDeviceId(deviceId, QSM));
171 * Call Spotify Api to play set the volume. If the device id is empty it will set the volume on the active device.
173 * @param deviceId device to set the Volume on or empty if set volume on the active device
174 * @param volumePercent volume percentage value to set
176 public void setVolume(String deviceId, int volumePercent) {
177 requestPlayer(PUT, String.format("volume?volume_percent=%1d", volumePercent) + optionalDeviceId(deviceId, AMP));
181 * Call Spotify Api to play set the repeat state. If the device id is empty it will set the repeat state on the
184 * @param deviceId device to set repeat state on or empty if set repeat on the active device
185 * @param repeateState set the Spotify repeat state
187 public void setRepeatState(String deviceId, String repeateState) {
188 requestPlayer(PUT, String.format("repeat?state=%s", repeateState) + optionalDeviceId(deviceId, AMP));
192 * Call Spotify Api to play set the shuffle. If the device id is empty it will set shuffle state on the active
195 * @param deviceId device to set shuffle state on or empty if set shuffle on the active device
196 * @param state the shuffle state to set
198 public void setShuffleState(String deviceId, OnOffType state) {
199 requestPlayer(PUT, String.format("shuffle?state=%s", state == OnOffType.OFF ? "false" : "true")
200 + optionalDeviceId(deviceId, AMP));
204 * Method to return an optional device id url pattern. If device id is empty an empty string is returned else the
205 * device id url query pattern prefixed with the given prefix char
207 * @param deviceId device to play on or empty if play on the active device
208 * @param prefix char to prefix to the deviceId string if present
209 * @return empty string or query string part for device id
211 private String optionalDeviceId(String deviceId, char prefix) {
212 return deviceId.isEmpty() ? "" : String.format("%cdevice_id=%s", prefix, deviceId);
216 * @return Calls Spotify Api and returns the list of device or an empty list if nothing was returned
218 public List<Device> getDevices() {
219 final Devices deviceList = requestPlayer(GET, "devices", "", Devices.class);
221 return deviceList == null || deviceList.getDevices() == null ? Collections.emptyList()
222 : deviceList.getDevices();
226 * @return Returns the playlists of the user.
228 public List<Playlist> getPlaylists(int offset, int limit) {
229 final Playlists playlists = request(GET, SPOTIFY_API_URL + "/playlists?offset" + offset + "&limit=" + limit, "",
232 return playlists == null || playlists.getItems() == null ? Collections.emptyList() : playlists.getItems();
236 * @return Calls Spotify Api and returns the current playing context of the user or an empty object if no context as
237 * returned by Spotify
239 public CurrentlyPlayingContext getPlayerInfo() {
240 final CurrentlyPlayingContext context = requestPlayer(GET, "", "", CurrentlyPlayingContext.class);
242 return context == null ? EMPTY_CURRENTLYPLAYINGCONTEXT : context;
246 * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
249 * @param method Http method to perform
250 * @param url url path to call to Spotify
252 private void requestPlayer(HttpMethod method, String url) {
253 requestPlayer(method, url, "", String.class);
257 * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
260 * @param method Http method to perform
261 * @param url url path to call to Spotify
262 * @param requestData data to pass along with the call as content
263 * @param clazz data type of return data, if null no data is expected to be returned.
264 * @return the response give by Spotify
266 private <T> @Nullable T requestPlayer(HttpMethod method, String url, String requestData, Class<T> clazz) {
267 return request(method, SPOTIFY_API_PLAYER_URL + (url.isEmpty() ? "" : ('/' + url)), requestData, clazz);
271 * Parses the Spotify returned json.
273 * @param <T> z data type to return
274 * @param content json content to parse
275 * @param clazz data type to return
276 * @throws SpotifyException throws a {@link SpotifyException} in case the json could not be parsed.
277 * @return parsed json.
279 private static <T> @Nullable T fromJson(String content, Class<T> clazz) {
281 return (T) ModelUtil.gsonInstance().fromJson(content, clazz);
282 } catch (final JsonSyntaxException e) {
283 throw new SpotifyException("Unknown Spotify response:" + content, e);
288 * Calls the Spotify Web Api with the given method and given url as parameters of the call to Spotify.
290 * @param method Http method to perform
291 * @param url url path to call to Spotify
292 * @param requestData data to pass along with the call as content
293 * @param clazz data type of return data, if null no data is expected to be returned.
294 * @return the response give by Spotify
296 private <T> @Nullable T request(HttpMethod method, String url, String requestData, Class<T> clazz) {
297 logger.debug("Request: ({}) {} - {}", method, url, requestData);
298 final Function<HttpClient, Request> call = httpClient -> httpClient.newRequest(url).method(method)
299 .header("Accept", CONTENT_TYPE).content(new StringContentProvider(requestData), CONTENT_TYPE);
301 final AccessTokenResponse accessTokenResponse = oAuthClientService.getAccessTokenResponse();
302 final String accessToken = accessTokenResponse == null ? null : accessTokenResponse.getAccessToken();
304 if (accessToken == null || accessToken.isEmpty()) {
305 throw new SpotifyAuthorizationException(
306 "No Spotify accesstoken. Did you authorize Spotify via /connectspotify ?");
308 final String response = requestWithRetry(call, accessToken).getContentAsString();
310 return clazz == String.class ? (@Nullable T) response : fromJson(response, clazz);
312 } catch (final IOException e) {
313 throw new SpotifyException(e.getMessage(), e);
314 } catch (OAuthException | OAuthResponseException e) {
315 throw new SpotifyAuthorizationException(e.getMessage(), e);
319 private ContentResponse requestWithRetry(final Function<HttpClient, Request> call, final String accessToken)
320 throws OAuthException, IOException, OAuthResponseException {
322 return connector.request(call, BEARER + accessToken);
323 } catch (final SpotifyTokenExpiredException e) {
324 // Retry with new access token
325 return connector.request(call, BEARER + oAuthClientService.refreshToken().getAccessToken());