]> git.basschouten.com Git - openhab-addons.git/blob
f3f18f8f496cbe1c7009445d77e7b42737a476e9
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.spotify.internal.api;
14
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;
20
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;
29
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;
54
55 import com.google.gson.JsonSyntaxException;
56
57 /**
58  * Class to handle Spotify Web Api calls.
59  *
60  * @author Andreas Stenlund - Initial contribution
61  * @author Hilbrand Bouwkamp - Refactored calling Web Api and simplified code
62  */
63 @NonNullByDefault
64 public class SpotifyApi {
65
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}";
74
75     private final Logger logger = LoggerFactory.getLogger(SpotifyApi.class);
76
77     private final OAuthClientService oAuthClientService;
78     private final SpotifyConnector connector;
79
80     /**
81      * Constructor.
82      *
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
85      */
86     public SpotifyApi(OAuthClientService oAuthClientService, ScheduledExecutorService scheduler,
87             HttpClient httpClient) {
88         this.oAuthClientService = oAuthClientService;
89         connector = new SpotifyConnector(scheduler, httpClient);
90     }
91
92     /**
93      * @return Returns the Spotify user information
94      */
95     public Me getMe() {
96         return Objects.requireNonNull(request(GET, SPOTIFY_API_URL, "", Me.class));
97     }
98
99     /**
100      * Call Spotify Api to play the given track on the given device. If the device id is empty it will be played on
101      * the active device.
102      *
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
107      */
108     public void playTrack(String deviceId, String trackId, int offset, int positionMs) {
109         final String url = "play" + optionalDeviceId(deviceId, QSM);
110         final String play;
111         if (trackId.contains(":track:")) {
112             play = String.format(PLAY_TRACK_URIS,
113                     Arrays.asList(trackId.split(",")).stream().map(t -> '"' + t + '"').collect(Collectors.joining(",")),
114                     offset, positionMs);
115         } else {
116             play = String.format(PLAY_TRACK_CONTEXT_URI, trackId, offset, positionMs);
117         }
118         requestPlayer(PUT, url, play, String.class);
119     }
120
121     /**
122      * Call Spotify Api to start playing. If the device id is empty it will start play of the active device.
123      *
124      * @param deviceId device to play on or empty if play on the active device
125      */
126     public void play(String deviceId) {
127         requestPlayer(PUT, "play" + optionalDeviceId(deviceId, QSM));
128     }
129
130     /**
131      * Call Spotify Api to transfer playing to. Depending on play value is start play or pause.
132      *
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.
135      */
136     public void transferPlay(String deviceId, boolean play) {
137         requestPlayer(PUT, "", String.format(TRANSFER_PLAY, deviceId, play), String.class);
138     }
139
140     /**
141      * Call Spotify Api to pause playing. If the device id is empty it will pause play of the active device.
142      *
143      * @param deviceId device to pause on or empty if pause on the active device
144      */
145     public void pause(String deviceId) {
146         requestPlayer(PUT, "pause" + optionalDeviceId(deviceId, QSM));
147     }
148
149     /**
150      * Call Spotify Api to play the next song. If the device id is empty it will play the next song on the active
151      * device.
152      *
153      * @param deviceId device to play next track on or empty if play next track on the active device
154      */
155     public void next(String deviceId) {
156         requestPlayer(POST, "next" + optionalDeviceId(deviceId, QSM));
157     }
158
159     /**
160      * Call Spotify Api to play the previous song. If the device id is empty it will play the previous song on the
161      * active device.
162      *
163      * @param deviceId device to play previous track on or empty if play previous track on the active device
164      */
165     public void previous(String deviceId) {
166         requestPlayer(POST, "previous" + optionalDeviceId(deviceId, QSM));
167     }
168
169     /**
170      * Call Spotify Api to play set the volume. If the device id is empty it will set the volume on the active device.
171      *
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
174      */
175     public void setVolume(String deviceId, int volumePercent) {
176         requestPlayer(PUT, String.format("volume?volume_percent=%1d", volumePercent) + optionalDeviceId(deviceId, AMP));
177     }
178
179     /**
180      * Call Spotify Api to play set the repeat state. If the device id is empty it will set the repeat state on the
181      * active device.
182      *
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
185      */
186     public void setRepeatState(String deviceId, String repeateState) {
187         requestPlayer(PUT, String.format("repeat?state=%s", repeateState) + optionalDeviceId(deviceId, AMP));
188     }
189
190     /**
191      * Call Spotify Api to play set the shuffle. If the device id is empty it will set shuffle state on the active
192      * device.
193      *
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
196      */
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));
200     }
201
202     /**
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
205      *
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
209      */
210     private String optionalDeviceId(String deviceId, char prefix) {
211         return deviceId.isEmpty() ? "" : String.format("%cdevice_id=%s", prefix, deviceId);
212     }
213
214     /**
215      * @return Calls Spotify Api and returns the list of device or an empty list if nothing was returned
216      */
217     public List<Device> getDevices() {
218         final Devices deviceList = requestPlayer(GET, "devices", "", Devices.class);
219
220         return deviceList == null || deviceList.getDevices() == null ? Collections.emptyList()
221                 : deviceList.getDevices();
222     }
223
224     /**
225      * @return Returns the playlists of the user.
226      */
227     public List<Playlist> getPlaylists(int offset, int limit) {
228         final Playlists playlists = request(GET, SPOTIFY_API_URL + "/playlists?offset" + offset + "&limit=" + limit, "",
229                 Playlists.class);
230
231         return playlists == null || playlists.getItems() == null ? Collections.emptyList() : playlists.getItems();
232     }
233
234     /**
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
237      */
238     public CurrentlyPlayingContext getPlayerInfo() {
239         final CurrentlyPlayingContext context = requestPlayer(GET, "", "", CurrentlyPlayingContext.class);
240
241         return context == null ? EMPTY_CURRENTLYPLAYINGCONTEXT : context;
242     }
243
244     /**
245      * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
246      * Spotify.
247      *
248      * @param method Http method to perform
249      * @param url url path to call to Spotify
250      */
251     private void requestPlayer(HttpMethod method, String url) {
252         requestPlayer(method, url, "", String.class);
253     }
254
255     /**
256      * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
257      * Spotify.
258      *
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
264      */
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);
267     }
268
269     /**
270      * Parses the Spotify returned json.
271      *
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.
277      */
278     private static <T> @Nullable T fromJson(String content, Class<T> clazz) {
279         try {
280             return (T) ModelUtil.gsonInstance().fromJson(content, clazz);
281         } catch (final JsonSyntaxException e) {
282             throw new SpotifyException("Unknown Spotify response:" + content, e);
283         }
284     }
285
286     /**
287      * Calls the Spotify Web Api with the given method and given url as parameters of the call to Spotify.
288      *
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
294      */
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);
299         try {
300             final AccessTokenResponse accessTokenResponse = oAuthClientService.getAccessTokenResponse();
301             final String accessToken = accessTokenResponse == null ? null : accessTokenResponse.getAccessToken();
302
303             if (accessToken == null || accessToken.isEmpty()) {
304                 throw new SpotifyAuthorizationException(
305                         "No Spotify accesstoken. Did you authorize Spotify via /connectspotify ?");
306             } else {
307                 final String response = requestWithRetry(call, accessToken).getContentAsString();
308
309                 return clazz == String.class ? (@Nullable T) response : fromJson(response, clazz);
310             }
311         } catch (final IOException e) {
312             throw new SpotifyException(e.getMessage(), e);
313         } catch (OAuthException | OAuthResponseException e) {
314             throw new SpotifyAuthorizationException(e.getMessage(), e);
315         }
316     }
317
318     private ContentResponse requestWithRetry(final Function<HttpClient, Request> call, final String accessToken)
319             throws OAuthException, IOException, OAuthResponseException {
320         try {
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());
325         }
326     }
327 }