]> git.basschouten.com Git - openhab-addons.git/blob
628c2cb1f02883f60aa9e829ba3e4fca8c83d23a
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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.*;
16 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.*;
17
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;
26
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;
50
51 /**
52  * Class to handle Spotify Web Api calls.
53  *
54  * @author Andreas Stenlund - Initial contribution
55  * @author Hilbrand Bouwkamp - Refactored calling Web Api and simplified code
56  */
57 @NonNullByDefault
58 public class SpotifyApi {
59
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}";
68
69     private final Logger logger = LoggerFactory.getLogger(SpotifyApi.class);
70
71     private final OAuthClientService oAuthClientService;
72     private final SpotifyConnector connector;
73
74     /**
75      * Constructor.
76      *
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
79      */
80     public SpotifyApi(OAuthClientService oAuthClientService, ScheduledExecutorService scheduler,
81             HttpClient httpClient) {
82         this.oAuthClientService = oAuthClientService;
83         connector = new SpotifyConnector(scheduler, httpClient);
84     }
85
86     /**
87      * @return Returns the Spotify user information
88      */
89     public Me getMe() {
90         final ContentResponse response = request(GET, SPOTIFY_API_URL, "");
91
92         return Objects.requireNonNull(ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Me.class));
93     }
94
95     /**
96      * Call Spotify Api to play the given track on the given device. If the device id is empty it will be played on
97      * the active device.
98      *
99      * @param deviceId device to play on or empty if play on the active device
100      * @param trackId id of the track to play
101      */
102     public void playTrack(String deviceId, String trackId) {
103         final String url = "play" + optionalDeviceId(deviceId, QSM);
104         final String play;
105         if (trackId.contains(":track:")) {
106             play = String.format(PLAY_TRACK_URIS, Arrays.asList(trackId.split(",")).stream().map(t -> '"' + t + '"')
107                     .collect(Collectors.joining(",")));
108         } else {
109             play = String.format(PLAY_TRACK_CONTEXT_URI, trackId);
110         }
111         requestPlayer(PUT, url, play);
112     }
113
114     /**
115      * Call Spotify Api to start playing. If the device id is empty it will start play of the active device.
116      *
117      * @param deviceId device to play on or empty if play on the active device
118      */
119     public void play(String deviceId) {
120         requestPlayer(PUT, "play" + optionalDeviceId(deviceId, QSM));
121     }
122
123     /**
124      * Call Spotify Api to transfer playing to. Depending on play value is start play or pause.
125      *
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.
128      */
129     public void transferPlay(String deviceId, boolean play) {
130         requestPlayer(PUT, "", String.format(TRANSFER_PLAY, deviceId, play));
131     }
132
133     /**
134      * Call Spotify Api to pause playing. If the device id is empty it will pause play of the active device.
135      *
136      * @param deviceId device to pause on or empty if pause on the active device
137      */
138     public void pause(String deviceId) {
139         requestPlayer(PUT, "pause" + optionalDeviceId(deviceId, QSM));
140     }
141
142     /**
143      * Call Spotify Api to play the next song. If the device id is empty it will play the next song on the active
144      * device.
145      *
146      * @param deviceId device to play next track on or empty if play next track on the active device
147      */
148     public void next(String deviceId) {
149         requestPlayer(POST, "next" + optionalDeviceId(deviceId, QSM));
150     }
151
152     /**
153      * Call Spotify Api to play the previous song. If the device id is empty it will play the previous song on the
154      * active device.
155      *
156      * @param deviceId device to play previous track on or empty if play previous track on the active device
157      */
158     public void previous(String deviceId) {
159         requestPlayer(POST, "previous" + optionalDeviceId(deviceId, QSM));
160     }
161
162     /**
163      * Call Spotify Api to play set the volume. If the device id is empty it will set the volume on the active device.
164      *
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
167      */
168     public void setVolume(String deviceId, int volumePercent) {
169         requestPlayer(PUT, String.format("volume?volume_percent=%1d", volumePercent) + optionalDeviceId(deviceId, AMP));
170     }
171
172     /**
173      * Call Spotify Api to play set the repeat state. If the device id is empty it will set the repeat state on the
174      * active device.
175      *
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
178      */
179     public void setRepeatState(String deviceId, String repeateState) {
180         requestPlayer(PUT, String.format("repeat?state=%s", repeateState) + optionalDeviceId(deviceId, AMP));
181     }
182
183     /**
184      * Call Spotify Api to play set the shuffle. If the device id is empty it will set shuffle state on the active
185      * device.
186      *
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
189      */
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));
193     }
194
195     /**
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
198      *
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
202      */
203     private String optionalDeviceId(String deviceId, char prefix) {
204         return deviceId.isEmpty() ? "" : String.format("%cdevice_id=%s", prefix, deviceId);
205     }
206
207     /**
208      * @return Calls Spotify Api and returns the list of device or an empty list if nothing was returned
209      */
210     public List<Device> getDevices() {
211         final ContentResponse response = requestPlayer(GET, "devices");
212         final Devices deviceList = ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Devices.class);
213
214         return deviceList == null || deviceList.getDevices() == null ? Collections.emptyList()
215                 : deviceList.getDevices();
216     }
217
218     /**
219      * @return Returns the playlists of the user.
220      */
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);
224
225         return playlists == null || playlists.getItems() == null ? Collections.emptyList() : playlists.getItems();
226     }
227
228     /**
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
231      */
232     public CurrentlyPlayingContext getPlayerInfo() {
233         final ContentResponse response = requestPlayer(GET, "");
234         final CurrentlyPlayingContext context = ModelUtil.gsonInstance().fromJson(response.getContentAsString(),
235                 CurrentlyPlayingContext.class);
236
237         return context == null ? EMPTY_CURRENTLYPLAYINGCONTEXT : context;
238     }
239
240     /**
241      * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
242      * Spotify.
243      *
244      * @param method Http method to perform
245      * @param url url path to call to spotify
246      * @return the response give by Spotify
247      */
248     private ContentResponse requestPlayer(HttpMethod method, String url) {
249         return requestPlayer(method, url, "");
250     }
251
252     /**
253      * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
254      * Spotify.
255      *
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
260      */
261     private ContentResponse requestPlayer(HttpMethod method, String url, String requestData) {
262         return request(method, SPOTIFY_API_PLAYER_URL + (url.isEmpty() ? "" : ('/' + url)), requestData);
263     }
264
265     /**
266      * Calls the Spotify Web Api with the given method and given url as parameters of the call to Spotify.
267      *
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
272      */
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);
277         try {
278             final AccessTokenResponse accessTokenResponse = oAuthClientService.getAccessTokenResponse();
279             final String accessToken = accessTokenResponse == null ? null : accessTokenResponse.getAccessToken();
280
281             if (accessToken == null || accessToken.isEmpty()) {
282                 throw new SpotifyAuthorizationException(
283                         "No spotify accesstoken. Did you authorize spotify via /connectspotify ?");
284             } else {
285                 return requestWithRetry(call, accessToken);
286             }
287         } catch (IOException e) {
288             throw new SpotifyException(e.getMessage(), e);
289         } catch (OAuthException | OAuthResponseException e) {
290             throw new SpotifyAuthorizationException(e.getMessage(), e);
291         }
292     }
293
294     private ContentResponse requestWithRetry(final Function<HttpClient, Request> call, final String accessToken)
295             throws OAuthException, IOException, OAuthResponseException {
296         try {
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());
301         }
302     }
303 }