]> git.basschouten.com Git - openhab-addons.git/blob
297ca2d87a51300f50e6e383aeae858b2918be9d
[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 oAuthClientService The authorizer used to refresh the access token when expired
84      * @param scheduler
85      * @param httpClient The Spotify connector handling the Web Api calls to Spotify
86      */
87     public SpotifyApi(OAuthClientService oAuthClientService, ScheduledExecutorService scheduler,
88             HttpClient httpClient) {
89         this.oAuthClientService = oAuthClientService;
90         connector = new SpotifyConnector(scheduler, httpClient);
91     }
92
93     /**
94      * @return Returns the Spotify user information
95      */
96     public Me getMe() {
97         return Objects.requireNonNull(request(GET, SPOTIFY_API_URL, "", Me.class));
98     }
99
100     /**
101      * Call Spotify Api to play the given track on the given device. If the device id is empty it will be played on
102      * the active device.
103      *
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
108      */
109     public void playTrack(String deviceId, String trackId, int offset, int positionMs) {
110         final String url = "play" + optionalDeviceId(deviceId, QSM);
111         final String play;
112         if (trackId.contains(":track:")) {
113             play = String.format(PLAY_TRACK_URIS,
114                     Arrays.asList(trackId.split(",")).stream().map(t -> '"' + t + '"').collect(Collectors.joining(",")),
115                     offset, positionMs);
116         } else {
117             play = String.format(PLAY_TRACK_CONTEXT_URI, trackId, offset, positionMs);
118         }
119         requestPlayer(PUT, url, play, String.class);
120     }
121
122     /**
123      * Call Spotify Api to start playing. If the device id is empty it will start play of the active device.
124      *
125      * @param deviceId device to play on or empty if play on the active device
126      */
127     public void play(String deviceId) {
128         requestPlayer(PUT, "play" + optionalDeviceId(deviceId, QSM));
129     }
130
131     /**
132      * Call Spotify Api to transfer playing to. Depending on play value is start play or pause.
133      *
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.
136      */
137     public void transferPlay(String deviceId, boolean play) {
138         requestPlayer(PUT, "", String.format(TRANSFER_PLAY, deviceId, play), String.class);
139     }
140
141     /**
142      * Call Spotify Api to pause playing. If the device id is empty it will pause play of the active device.
143      *
144      * @param deviceId device to pause on or empty if pause on the active device
145      */
146     public void pause(String deviceId) {
147         requestPlayer(PUT, "pause" + optionalDeviceId(deviceId, QSM));
148     }
149
150     /**
151      * Call Spotify Api to play the next song. If the device id is empty it will play the next song on the active
152      * device.
153      *
154      * @param deviceId device to play next track on or empty if play next track on the active device
155      */
156     public void next(String deviceId) {
157         requestPlayer(POST, "next" + optionalDeviceId(deviceId, QSM));
158     }
159
160     /**
161      * Call Spotify Api to play the previous song. If the device id is empty it will play the previous song on the
162      * active device.
163      *
164      * @param deviceId device to play previous track on or empty if play previous track on the active device
165      */
166     public void previous(String deviceId) {
167         requestPlayer(POST, "previous" + optionalDeviceId(deviceId, QSM));
168     }
169
170     /**
171      * Call Spotify Api to play set the volume. If the device id is empty it will set the volume on the active device.
172      *
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
175      */
176     public void setVolume(String deviceId, int volumePercent) {
177         requestPlayer(PUT, String.format("volume?volume_percent=%1d", volumePercent) + optionalDeviceId(deviceId, AMP));
178     }
179
180     /**
181      * Call Spotify Api to play set the repeat state. If the device id is empty it will set the repeat state on the
182      * active device.
183      *
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
186      */
187     public void setRepeatState(String deviceId, String repeateState) {
188         requestPlayer(PUT, String.format("repeat?state=%s", repeateState) + optionalDeviceId(deviceId, AMP));
189     }
190
191     /**
192      * Call Spotify Api to play set the shuffle. If the device id is empty it will set shuffle state on the active
193      * device.
194      *
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
197      */
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));
201     }
202
203     /**
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
206      *
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
210      */
211     private String optionalDeviceId(String deviceId, char prefix) {
212         return deviceId.isEmpty() ? "" : String.format("%cdevice_id=%s", prefix, deviceId);
213     }
214
215     /**
216      * @return Calls Spotify Api and returns the list of device or an empty list if nothing was returned
217      */
218     public List<Device> getDevices() {
219         final Devices deviceList = requestPlayer(GET, "devices", "", Devices.class);
220
221         return deviceList == null || deviceList.getDevices() == null ? Collections.emptyList()
222                 : deviceList.getDevices();
223     }
224
225     /**
226      * @return Returns the playlists of the user.
227      */
228     public List<Playlist> getPlaylists(int offset, int limit) {
229         final Playlists playlists = request(GET, SPOTIFY_API_URL + "/playlists?offset" + offset + "&limit=" + limit, "",
230                 Playlists.class);
231
232         return playlists == null || playlists.getItems() == null ? Collections.emptyList() : playlists.getItems();
233     }
234
235     /**
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
238      */
239     public CurrentlyPlayingContext getPlayerInfo() {
240         final CurrentlyPlayingContext context = requestPlayer(GET, "", "", CurrentlyPlayingContext.class);
241
242         return context == null ? EMPTY_CURRENTLYPLAYINGCONTEXT : context;
243     }
244
245     /**
246      * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
247      * Spotify.
248      *
249      * @param method Http method to perform
250      * @param url url path to call to Spotify
251      */
252     private void requestPlayer(HttpMethod method, String url) {
253         requestPlayer(method, url, "", String.class);
254     }
255
256     /**
257      * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
258      * Spotify.
259      *
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
265      */
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);
268     }
269
270     /**
271      * Parses the Spotify returned json.
272      *
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.
278      */
279     private static <T> @Nullable T fromJson(String content, Class<T> clazz) {
280         try {
281             return (T) ModelUtil.gsonInstance().fromJson(content, clazz);
282         } catch (final JsonSyntaxException e) {
283             throw new SpotifyException("Unknown Spotify response:" + content, e);
284         }
285     }
286
287     /**
288      * Calls the Spotify Web Api with the given method and given url as parameters of the call to Spotify.
289      *
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
295      */
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);
300         try {
301             final AccessTokenResponse accessTokenResponse = oAuthClientService.getAccessTokenResponse();
302             final String accessToken = accessTokenResponse == null ? null : accessTokenResponse.getAccessToken();
303
304             if (accessToken == null || accessToken.isEmpty()) {
305                 throw new SpotifyAuthorizationException(
306                         "No Spotify accesstoken. Did you authorize Spotify via /connectspotify ?");
307             } else {
308                 final String response = requestWithRetry(call, accessToken).getContentAsString();
309
310                 return clazz == String.class ? (@Nullable T) response : fromJson(response, clazz);
311             }
312         } catch (final IOException e) {
313             throw new SpotifyException(e.getMessage(), e);
314         } catch (OAuthException | OAuthResponseException e) {
315             throw new SpotifyAuthorizationException(e.getMessage(), e);
316         }
317     }
318
319     private ContentResponse requestWithRetry(final Function<HttpClient, Request> call, final String accessToken)
320             throws OAuthException, IOException, OAuthResponseException {
321         try {
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());
326         }
327     }
328 }