]> git.basschouten.com Git - openhab-addons.git/blob
afda601eaf63c74f08465f437743358a75c322f3
[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.concurrent.ScheduledExecutorService;
23 import java.util.function.Function;
24 import java.util.stream.Collectors;
25
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jetty.client.HttpClient;
28 import org.eclipse.jetty.client.api.ContentResponse;
29 import org.eclipse.jetty.client.api.Request;
30 import org.eclipse.jetty.client.util.StringContentProvider;
31 import org.eclipse.jetty.http.HttpMethod;
32 import org.openhab.binding.spotify.internal.api.exception.SpotifyAuthorizationException;
33 import org.openhab.binding.spotify.internal.api.exception.SpotifyException;
34 import org.openhab.binding.spotify.internal.api.exception.SpotifyTokenExpiredException;
35 import org.openhab.binding.spotify.internal.api.model.CurrentlyPlayingContext;
36 import org.openhab.binding.spotify.internal.api.model.Device;
37 import org.openhab.binding.spotify.internal.api.model.Devices;
38 import org.openhab.binding.spotify.internal.api.model.Me;
39 import org.openhab.binding.spotify.internal.api.model.ModelUtil;
40 import org.openhab.binding.spotify.internal.api.model.Playlist;
41 import org.openhab.binding.spotify.internal.api.model.Playlists;
42 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
43 import org.openhab.core.auth.client.oauth2.OAuthClientService;
44 import org.openhab.core.auth.client.oauth2.OAuthException;
45 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
46 import org.openhab.core.library.types.OnOffType;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
49
50 /**
51  * Class to handle Spotify Web Api calls.
52  *
53  * @author Andreas Stenlund - Initial contribution
54  * @author Hilbrand Bouwkamp - Refactored calling Web Api and simplified code
55  */
56 @NonNullByDefault
57 public class SpotifyApi {
58
59     private static final String CONTENT_TYPE = "application/json";
60     private static final String BEARER = "Bearer ";
61     private static final char AMP = '&';
62     private static final char QSM = '?';
63     private static final CurrentlyPlayingContext EMPTY_CURRENTLYPLAYINGCONTEXT = new CurrentlyPlayingContext();
64     private static final String PLAY_TRACK_URIS = "{\"uris\":[%s],\"offset\":{\"position\":0}}";
65     private static final String PLAY_TRACK_CONTEXT_URI = "{\"context_uri\":\"%s\",\"offset\":{\"position\":0}}";
66     private static final String TRANSFER_PLAY = "{\"device_ids\":[\"%s\"],\"play\":%b}";
67
68     private final Logger logger = LoggerFactory.getLogger(SpotifyApi.class);
69
70     private final OAuthClientService oAuthClientService;
71     private final SpotifyConnector connector;
72
73     /**
74      * Constructor.
75      *
76      * @param authorizer The authorizer used to refresh the access token when expired
77      * @param connector The Spotify connector handling the Web Api calls to Spotify
78      */
79     public SpotifyApi(OAuthClientService oAuthClientService, ScheduledExecutorService scheduler,
80             HttpClient httpClient) {
81         this.oAuthClientService = oAuthClientService;
82         connector = new SpotifyConnector(scheduler, httpClient);
83     }
84
85     /**
86      * @return Returns the Spotify user information
87      */
88     public Me getMe() {
89         final ContentResponse response = request(GET, SPOTIFY_API_URL, "");
90
91         return ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Me.class);
92     }
93
94     /**
95      * Call Spotify Api to play the given track on the given device. If the device id is empty it will be played on
96      * the active device.
97      *
98      * @param deviceId device to play on or empty if play on the active device
99      * @param trackId id of the track to play
100      */
101     public void playTrack(String deviceId, String trackId) {
102         final String url = "play" + optionalDeviceId(deviceId, QSM);
103         final String play;
104         if (trackId.contains(":track:")) {
105             play = String.format(PLAY_TRACK_URIS, Arrays.asList(trackId.split(",")).stream().map(t -> '"' + t + '"')
106                     .collect(Collectors.joining(",")));
107         } else {
108             play = String.format(PLAY_TRACK_CONTEXT_URI, trackId);
109         }
110         requestPlayer(PUT, url, play);
111     }
112
113     /**
114      * Call Spotify Api to start playing. If the device id is empty it will start play of the active device.
115      *
116      * @param deviceId device to play on or empty if play on the active device
117      */
118     public void play(String deviceId) {
119         requestPlayer(PUT, "play" + optionalDeviceId(deviceId, QSM));
120     }
121
122     /**
123      * Call Spotify Api to transfer playing to. Depending on play value is start play or pause.
124      *
125      * @param deviceId device to play on. It can not be empty.
126      * @param play if true transfers and starts to play, else transfers but pauses.
127      */
128     public void transferPlay(String deviceId, boolean play) {
129         requestPlayer(PUT, "", String.format(TRANSFER_PLAY, deviceId, play));
130     }
131
132     /**
133      * Call Spotify Api to pause playing. If the device id is empty it will pause play of the active device.
134      *
135      * @param deviceId device to pause on or empty if pause on the active device
136      */
137     public void pause(String deviceId) {
138         requestPlayer(PUT, "pause" + optionalDeviceId(deviceId, QSM));
139     }
140
141     /**
142      * Call Spotify Api to play the next song. If the device id is empty it will play the next song on the active
143      * device.
144      *
145      * @param deviceId device to play next track on or empty if play next track on the active device
146      */
147     public void next(String deviceId) {
148         requestPlayer(POST, "next" + optionalDeviceId(deviceId, QSM));
149     }
150
151     /**
152      * Call Spotify Api to play the previous song. If the device id is empty it will play the previous song on the
153      * active device.
154      *
155      * @param deviceId device to play previous track on or empty if play previous track on the active device
156      */
157     public void previous(String deviceId) {
158         requestPlayer(POST, "previous" + optionalDeviceId(deviceId, QSM));
159     }
160
161     /**
162      * Call Spotify Api to play set the volume. If the device id is empty it will set the volume on the active device.
163      *
164      * @param deviceId device to set the Volume on or empty if set volume on the active device
165      * @param volumePercent volume percentage value to set
166      */
167     public void setVolume(String deviceId, int volumePercent) {
168         requestPlayer(PUT, String.format("volume?volume_percent=%1d", volumePercent) + optionalDeviceId(deviceId, AMP));
169     }
170
171     /**
172      * Call Spotify Api to play set the repeat state. If the device id is empty it will set the repeat state on the
173      * active device.
174      *
175      * @param deviceId device to set repeat state on or empty if set repeat on the active device
176      * @param repeateState set the spotify repeat state
177      */
178     public void setRepeatState(String deviceId, String repeateState) {
179         requestPlayer(PUT, String.format("repeat?state=%s", repeateState) + optionalDeviceId(deviceId, AMP));
180     }
181
182     /**
183      * Call Spotify Api to play set the shuffle. If the device id is empty it will set shuffle state on the active
184      * device.
185      *
186      * @param deviceId device to set shuffle state on or empty if set shuffle on the active device
187      * @param state the shuffle state to set
188      */
189     public void setShuffleState(String deviceId, OnOffType state) {
190         requestPlayer(PUT, String.format("shuffle?state=%s", state == OnOffType.OFF ? "false" : "true")
191                 + optionalDeviceId(deviceId, AMP));
192     }
193
194     /**
195      * Method to return an optional device id url pattern. If device id is empty an empty string is returned else the
196      * device id url query pattern prefixed with the given prefix char
197      *
198      * @param deviceId device to play on or empty if play on the active device
199      * @param prefix char to prefix to the deviceId string if present
200      * @return empty string or query string part for device id
201      */
202     private String optionalDeviceId(String deviceId, char prefix) {
203         return deviceId.isEmpty() ? "" : String.format("%cdevice_id=%s", prefix, deviceId);
204     }
205
206     /**
207      * @return Calls Spotify Api and returns the list of device or an empty list if nothing was returned
208      */
209     public List<Device> getDevices() {
210         final ContentResponse response = requestPlayer(GET, "devices");
211         final Devices deviceList = ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Devices.class);
212
213         return deviceList == null || deviceList.getDevices() == null ? Collections.emptyList()
214                 : deviceList.getDevices();
215     }
216
217     /**
218      * @return Returns the playlists of the user.
219      */
220     public List<Playlist> getPlaylists() {
221         final ContentResponse response = request(GET, SPOTIFY_API_URL + "/playlists", "");
222         final Playlists playlists = ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Playlists.class);
223
224         return playlists == null || playlists.getItems() == null ? Collections.emptyList() : playlists.getItems();
225     }
226
227     /**
228      * @return Calls Spotify Api and returns the current playing context of the user or an empty object if no context as
229      *         returned by Spotify
230      */
231     public CurrentlyPlayingContext getPlayerInfo() {
232         final ContentResponse response = requestPlayer(GET, "");
233         final CurrentlyPlayingContext context = ModelUtil.gsonInstance().fromJson(response.getContentAsString(),
234                 CurrentlyPlayingContext.class);
235
236         return context == null ? EMPTY_CURRENTLYPLAYINGCONTEXT : context;
237     }
238
239     /**
240      * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
241      * Spotify.
242      *
243      * @param method Http method to perform
244      * @param url url path to call to spotify
245      * @return the response give by Spotify
246      */
247     private ContentResponse requestPlayer(HttpMethod method, String url) {
248         return requestPlayer(method, url, "");
249     }
250
251     /**
252      * Calls the Spotify player Web Api with the given method and appends the given url as parameters of the call to
253      * Spotify.
254      *
255      * @param method Http method to perform
256      * @param url url path to call to spotify
257      * @param requestData data to pass along with the call as content
258      * @return the response give by Spotify
259      */
260     private ContentResponse requestPlayer(HttpMethod method, String url, String requestData) {
261         return request(method, SPOTIFY_API_PLAYER_URL + (url.isEmpty() ? "" : ('/' + url)), requestData);
262     }
263
264     /**
265      * Calls the Spotify Web Api with the given method and given url as parameters of the call to Spotify.
266      *
267      * @param method Http method to perform
268      * @param url url path to call to spotify
269      * @param requestData data to pass along with the call as content
270      * @return the response give by Spotify
271      */
272     private ContentResponse request(HttpMethod method, String url, String requestData) {
273         logger.debug("Request: ({}) {} - {}", method, url, requestData);
274         final Function<HttpClient, Request> call = httpClient -> httpClient.newRequest(url).method(method)
275                 .header("Accept", CONTENT_TYPE).content(new StringContentProvider(requestData), CONTENT_TYPE);
276         try {
277             final AccessTokenResponse accessTokenResponse = oAuthClientService.getAccessTokenResponse();
278             final String accessToken = accessTokenResponse == null ? null : accessTokenResponse.getAccessToken();
279
280             if (accessToken == null || accessToken.isEmpty()) {
281                 throw new SpotifyAuthorizationException(
282                         "No spotify accesstoken. Did you authorize spotify via /connectspotify ?");
283             } else {
284                 return requestWithRetry(call, accessToken);
285             }
286         } catch (IOException e) {
287             throw new SpotifyException(e.getMessage(), e);
288         } catch (OAuthException | OAuthResponseException e) {
289             throw new SpotifyAuthorizationException(e.getMessage(), e);
290         }
291     }
292
293     private ContentResponse requestWithRetry(final Function<HttpClient, Request> call, final String accessToken)
294             throws OAuthException, IOException, OAuthResponseException {
295         try {
296             return connector.request(call, BEARER + accessToken);
297         } catch (SpotifyTokenExpiredException e) {
298             // Retry with new access token
299             return connector.request(call, BEARER + oAuthClientService.refreshToken().getAccessToken());
300         }
301     }
302 }