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 authorizer The authorizer used to refresh the access token when expired
84 * @param connector The Spotify connector handling the Web Api calls to Spotify
86 public SpotifyApi(OAuthClientService oAuthClientService, ScheduledExecutorService scheduler,
87 HttpClient httpClient) {
88 this.oAuthClientService = oAuthClientService;
89 connector = new SpotifyConnector(scheduler, httpClient);
93 * @return Returns the Spotify user information
96 return Objects.requireNonNull(request(GET, SPOTIFY_API_URL, "", Me.class));
100 * Call Spotify Api to play the given track on the given device. If the device id is empty it will be played on
103 * @param deviceId device to play on or empty if play on the active device
104 * @param trackId id of the track to play
105 * @param offset offset
106 * @param positionMs position in ms
108 public void playTrack(String deviceId, String trackId, int offset, int positionMs) {
109 final String url = "play" + optionalDeviceId(deviceId, QSM);
111 if (trackId.contains(":track:")) {
112 play = String.format(PLAY_TRACK_URIS,
113 Arrays.asList(trackId.split(",")).stream().map(t -> '"' + t + '"').collect(Collectors.joining(",")),
116 play = String.format(PLAY_TRACK_CONTEXT_URI, trackId, offset, positionMs);
118 requestPlayer(PUT, url, play, String.class);
122 * Call Spotify Api to start playing. If the device id is empty it will start play of the active device.
124 * @param deviceId device to play on or empty if play on the active device
126 public void play(String deviceId) {
127 requestPlayer(PUT, "play" + optionalDeviceId(deviceId, QSM));
131 * Call Spotify Api to transfer playing to. Depending on play value is start play or pause.
133 * @param deviceId device to play on. It can not be empty.
134 * @param play if true transfers and starts to play, else transfers but pauses.
136 public void transferPlay(String deviceId, boolean play) {
137 requestPlayer(PUT, "", String.format(TRANSFER_PLAY, deviceId, play), String.class);
141 * Call Spotify Api to pause playing. If the device id is empty it will pause play of the active device.
143 * @param deviceId device to pause on or empty if pause on the active device
145 public void pause(String deviceId) {
146 requestPlayer(PUT, "pause" + optionalDeviceId(deviceId, QSM));
150 * Call Spotify Api to play the next song. If the device id is empty it will play the next song on the active
153 * @param deviceId device to play next track on or empty if play next track on the active device
155 public void next(String deviceId) {
156 requestPlayer(POST, "next" + optionalDeviceId(deviceId, QSM));
160 * Call Spotify Api to play the previous song. If the device id is empty it will play the previous song on the
163 * @param deviceId device to play previous track on or empty if play previous track on the active device
165 public void previous(String deviceId) {
166 requestPlayer(POST, "previous" + optionalDeviceId(deviceId, QSM));
170 * Call Spotify Api to play set the volume. If the device id is empty it will set the volume on the active device.
172 * @param deviceId device to set the Volume on or empty if set volume on the active device
173 * @param volumePercent volume percentage value to set
175 public void setVolume(String deviceId, int volumePercent) {
176 requestPlayer(PUT, String.format("volume?volume_percent=%1d", volumePercent) + optionalDeviceId(deviceId, AMP));
180 * Call Spotify Api to play set the repeat state. If the device id is empty it will set the repeat state on the
183 * @param deviceId device to set repeat state on or empty if set repeat on the active device
184 * @param repeateState set the Spotify repeat state
186 public void setRepeatState(String deviceId, String repeateState) {
187 requestPlayer(PUT, String.format("repeat?state=%s", repeateState) + optionalDeviceId(deviceId, AMP));
191 * Call Spotify Api to play set the shuffle. If the device id is empty it will set shuffle state on the active
194 * @param deviceId device to set shuffle state on or empty if set shuffle on the active device
195 * @param state the shuffle state to set
197 public void setShuffleState(String deviceId, OnOffType state) {
198 requestPlayer(PUT, String.format("shuffle?state=%s", state == OnOffType.OFF ? "false" : "true")
199 + optionalDeviceId(deviceId, AMP));
203 * Method to return an optional device id url pattern. If device id is empty an empty string is returned else the
204 * device id url query pattern prefixed with the given prefix char
206 * @param deviceId device to play on or empty if play on the active device
207 * @param prefix char to prefix to the deviceId string if present
208 * @return empty string or query string part for device id
210 private String optionalDeviceId(String deviceId, char prefix) {
211 return deviceId.isEmpty() ? "" : String.format("%cdevice_id=%s", prefix, deviceId);
215 * @return Calls Spotify Api and returns the list of device or an empty list if nothing was returned
217 public List<Device> getDevices() {
218 final Devices deviceList = requestPlayer(GET, "devices", "", Devices.class);
220 return deviceList == null || deviceList.getDevices() == null ? Collections.emptyList()
221 : deviceList.getDevices();
225 * @return Returns the playlists of the user.
227 public List<Playlist> getPlaylists(int offset, int limit) {
228 final Playlists playlists = request(GET, SPOTIFY_API_URL + "/playlists?offset" + offset + "&limit=" + limit, "",
231 return playlists == null || playlists.getItems() == null ? Collections.emptyList() : playlists.getItems();
235 * @return Calls Spotify Api and returns the current playing context of the user or an empty object if no context as
236 * returned by Spotify
238 public CurrentlyPlayingContext getPlayerInfo() {
239 final CurrentlyPlayingContext context = requestPlayer(GET, "", "", CurrentlyPlayingContext.class);
241 return context == null ? EMPTY_CURRENTLYPLAYINGCONTEXT : context;
245 * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
248 * @param method Http method to perform
249 * @param url url path to call to Spotify
251 private void requestPlayer(HttpMethod method, String url) {
252 requestPlayer(method, url, "", String.class);
256 * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
259 * @param method Http method to perform
260 * @param url url path to call to Spotify
261 * @param requestData data to pass along with the call as content
262 * @param clazz data type of return data, if null no data is expected to be returned.
263 * @return the response give by Spotify
265 private <T> @Nullable T requestPlayer(HttpMethod method, String url, String requestData, Class<T> clazz) {
266 return request(method, SPOTIFY_API_PLAYER_URL + (url.isEmpty() ? "" : ('/' + url)), requestData, clazz);
270 * Parses the Spotify returned json.
272 * @param <T> z data type to return
273 * @param content json content to parse
274 * @param clazz data type to return
275 * @throws SpotifyException throws a {@link SpotifyException} in case the json could not be parsed.
276 * @return parsed json.
278 private static <T> @Nullable T fromJson(String content, Class<T> clazz) {
280 return (T) ModelUtil.gsonInstance().fromJson(content, clazz);
281 } catch (final JsonSyntaxException e) {
282 throw new SpotifyException("Unknown Spotify response:" + content, e);
287 * Calls the Spotify Web Api with the given method and given url as parameters of the call to Spotify.
289 * @param method Http method to perform
290 * @param url url path to call to Spotify
291 * @param requestData data to pass along with the call as content
292 * @param clazz data type of return data, if null no data is expected to be returned.
293 * @return the response give by Spotify
295 private <T> @Nullable T request(HttpMethod method, String url, String requestData, Class<T> clazz) {
296 logger.debug("Request: ({}) {} - {}", method, url, requestData);
297 final Function<HttpClient, Request> call = httpClient -> httpClient.newRequest(url).method(method)
298 .header("Accept", CONTENT_TYPE).content(new StringContentProvider(requestData), CONTENT_TYPE);
300 final AccessTokenResponse accessTokenResponse = oAuthClientService.getAccessTokenResponse();
301 final String accessToken = accessTokenResponse == null ? null : accessTokenResponse.getAccessToken();
303 if (accessToken == null || accessToken.isEmpty()) {
304 throw new SpotifyAuthorizationException(
305 "No Spotify accesstoken. Did you authorize Spotify via /connectspotify ?");
307 final String response = requestWithRetry(call, accessToken).getContentAsString();
309 return clazz == String.class ? (@Nullable T) response : fromJson(response, clazz);
311 } catch (final IOException e) {
312 throw new SpotifyException(e.getMessage(), e);
313 } catch (OAuthException | OAuthResponseException e) {
314 throw new SpotifyAuthorizationException(e.getMessage(), e);
318 private ContentResponse requestWithRetry(final Function<HttpClient, Request> call, final String accessToken)
319 throws OAuthException, IOException, OAuthResponseException {
321 return connector.request(call, BEARER + accessToken);
322 } catch (final SpotifyTokenExpiredException e) {
323 // Retry with new access token
324 return connector.request(call, BEARER + oAuthClientService.refreshToken().getAccessToken());