]> git.basschouten.com Git - openhab-addons.git/blob
3d9e56399ae0754b449d7b76278ad9910460182a
[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.handler;
14
15 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_ACCESSTOKEN;
16 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_CONFIG_IMAGE_INDEX;
17 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICEACTIVE;
18 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICEID;
19 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICENAME;
20 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICES;
21 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICESHUFFLE;
22 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICETYPE;
23 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICEVOLUME;
24 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMHREF;
25 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMID;
26 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMIMAGE;
27 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMIMAGEURL;
28 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMNAME;
29 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMTYPE;
30 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMURI;
31 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ARTISTHREF;
32 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ARTISTID;
33 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ARTISTNAME;
34 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ARTISTTYPE;
35 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ARTISTURI;
36 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKDISCNUMBER;
37 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKDURATION_FMT;
38 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKDURATION_MS;
39 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKEXPLICIT;
40 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKHREF;
41 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKID;
42 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKNAME;
43 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKNUMBER;
44 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKPOPULARITY;
45 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKPROGRESS_FMT;
46 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKPROGRESS_MS;
47 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKTYPE;
48 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKURI;
49 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYLISTNAME;
50 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYLISTS;
51 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYLISTS_LIMIT;
52 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYLISTS_OFFSET;
53 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_TRACKPLAYER;
54 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_TRACKREPEAT;
55 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.PROPERTY_SPOTIFY_USER;
56 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.SPOTIFY_API_TOKEN_URL;
57 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.SPOTIFY_AUTHORIZE_URL;
58 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.SPOTIFY_SCOPES;
59
60 import java.io.IOException;
61 import java.math.BigDecimal;
62 import java.text.SimpleDateFormat;
63 import java.time.Duration;
64 import java.util.Collection;
65 import java.util.Collections;
66 import java.util.Date;
67 import java.util.List;
68 import java.util.Map;
69 import java.util.Optional;
70 import java.util.concurrent.Future;
71 import java.util.concurrent.TimeUnit;
72
73 import org.eclipse.jdt.annotation.NonNullByDefault;
74 import org.eclipse.jdt.annotation.Nullable;
75 import org.eclipse.jetty.client.HttpClient;
76 import org.openhab.binding.spotify.internal.SpotifyAccountHandler;
77 import org.openhab.binding.spotify.internal.SpotifyBridgeConfiguration;
78 import org.openhab.binding.spotify.internal.actions.SpotifyActions;
79 import org.openhab.binding.spotify.internal.api.SpotifyApi;
80 import org.openhab.binding.spotify.internal.api.exception.SpotifyAuthorizationException;
81 import org.openhab.binding.spotify.internal.api.exception.SpotifyException;
82 import org.openhab.binding.spotify.internal.api.model.Album;
83 import org.openhab.binding.spotify.internal.api.model.Artist;
84 import org.openhab.binding.spotify.internal.api.model.Context;
85 import org.openhab.binding.spotify.internal.api.model.CurrentlyPlayingContext;
86 import org.openhab.binding.spotify.internal.api.model.Device;
87 import org.openhab.binding.spotify.internal.api.model.Image;
88 import org.openhab.binding.spotify.internal.api.model.Item;
89 import org.openhab.binding.spotify.internal.api.model.Me;
90 import org.openhab.binding.spotify.internal.api.model.Playlist;
91 import org.openhab.binding.spotify.internal.discovery.SpotifyDeviceDiscoveryService;
92 import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
93 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
94 import org.openhab.core.auth.client.oauth2.OAuthClientService;
95 import org.openhab.core.auth.client.oauth2.OAuthException;
96 import org.openhab.core.auth.client.oauth2.OAuthFactory;
97 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
98 import org.openhab.core.cache.ExpiringCache;
99 import org.openhab.core.io.net.http.HttpUtil;
100 import org.openhab.core.library.types.DecimalType;
101 import org.openhab.core.library.types.OnOffType;
102 import org.openhab.core.library.types.PercentType;
103 import org.openhab.core.library.types.PlayPauseType;
104 import org.openhab.core.library.types.RawType;
105 import org.openhab.core.library.types.StringType;
106 import org.openhab.core.thing.Bridge;
107 import org.openhab.core.thing.Channel;
108 import org.openhab.core.thing.ChannelUID;
109 import org.openhab.core.thing.ThingStatus;
110 import org.openhab.core.thing.ThingStatusDetail;
111 import org.openhab.core.thing.ThingUID;
112 import org.openhab.core.thing.binding.BaseBridgeHandler;
113 import org.openhab.core.thing.binding.ThingHandlerService;
114 import org.openhab.core.types.Command;
115 import org.openhab.core.types.RefreshType;
116 import org.openhab.core.types.State;
117 import org.openhab.core.types.UnDefType;
118 import org.slf4j.Logger;
119 import org.slf4j.LoggerFactory;
120
121 /**
122  * The {@link SpotifyBridgeHandler} is the main class to manage Spotify WebAPI connection and update status of things.
123  *
124  * @author Andreas Stenlund - Initial contribution
125  * @author Hilbrand Bouwkamp - Just a lot of refactoring
126  */
127 @NonNullByDefault
128 public class SpotifyBridgeHandler extends BaseBridgeHandler
129         implements SpotifyAccountHandler, AccessTokenRefreshListener {
130
131     private static final CurrentlyPlayingContext EMPTY_CURRENTLY_PLAYING_CONTEXT = new CurrentlyPlayingContext();
132     private static final Album EMPTY_ALBUM = new Album();
133     private static final Artist EMPTY_ARTIST = new Artist();
134     private static final Item EMPTY_ITEM = new Item();
135     private static final Device EMPTY_DEVICE = new Device();
136     private static final SimpleDateFormat MUSIC_TIME_FORMAT = new SimpleDateFormat("m:ss");
137     private static final int MAX_IMAGE_SIZE = 500000;
138     /**
139      * Only poll playlist once per hour (or when refresh is called).
140      */
141     private static final Duration POLL_PLAY_LIST_HOURS = Duration.ofHours(1);
142     /**
143      * After a command is handles. With the given delay a status poll request is triggered. The delay is to give Spotify
144      * some time to handle the update.
145      */
146     private static final int POLL_DELAY_AFTER_COMMAND_S = 2;
147     /**
148      * Time between track progress status updates.
149      */
150     private static final int PROGRESS_STEP_S = 1;
151     private static final long PROGRESS_STEP_MS = TimeUnit.SECONDS.toMillis(PROGRESS_STEP_S);
152
153     private final Logger logger = LoggerFactory.getLogger(SpotifyBridgeHandler.class);
154     // Object to synchronize poll status on
155     private final Object pollSynchronization = new Object();
156     private final ProgressUpdater progressUpdater = new ProgressUpdater();
157     private final AlbumUpdater albumUpdater = new AlbumUpdater();
158     private final OAuthFactory oAuthFactory;
159     private final HttpClient httpClient;
160     private final SpotifyDynamicStateDescriptionProvider spotifyDynamicStateDescriptionProvider;
161     private final ChannelUID devicesChannelUID;
162     private final ChannelUID playlistsChannelUID;
163
164     // Field members assigned in initialize method
165     private @NonNullByDefault({}) Future<?> pollingFuture;
166     private @NonNullByDefault({}) OAuthClientService oAuthService;
167     private @NonNullByDefault({}) SpotifyApi spotifyApi;
168     private @NonNullByDefault({}) SpotifyBridgeConfiguration configuration;
169     private @NonNullByDefault({}) SpotifyHandleCommands handleCommand;
170     private @NonNullByDefault({}) ExpiringCache<CurrentlyPlayingContext> playingContextCache;
171     private @NonNullByDefault({}) ExpiringCache<List<Playlist>> playlistCache;
172     private @NonNullByDefault({}) ExpiringCache<List<Device>> devicesCache;
173
174     /**
175      * Keep track if this instance is disposed. This avoids new scheduling to be started after dispose is called.
176      */
177     private volatile boolean active;
178     private volatile State lastTrackId = StringType.EMPTY;
179     private volatile String lastKnownDeviceId = "";
180     private volatile boolean lastKnownDeviceActive;
181     private int imageChannelAlbumImageIndex;
182     private int imageChannelAlbumImageUrlIndex;
183
184     public SpotifyBridgeHandler(Bridge bridge, OAuthFactory oAuthFactory, HttpClient httpClient,
185             SpotifyDynamicStateDescriptionProvider spotifyDynamicStateDescriptionProvider) {
186         super(bridge);
187         this.oAuthFactory = oAuthFactory;
188         this.httpClient = httpClient;
189         this.spotifyDynamicStateDescriptionProvider = spotifyDynamicStateDescriptionProvider;
190         devicesChannelUID = new ChannelUID(bridge.getUID(), CHANNEL_DEVICES);
191         playlistsChannelUID = new ChannelUID(bridge.getUID(), CHANNEL_PLAYLISTS);
192     }
193
194     @Override
195     public Collection<Class<? extends ThingHandlerService>> getServices() {
196         return List.of(SpotifyActions.class, SpotifyDeviceDiscoveryService.class);
197     }
198
199     @Override
200     public void handleCommand(ChannelUID channelUID, Command command) {
201         if (command instanceof RefreshType) {
202             switch (channelUID.getId()) {
203                 case CHANNEL_PLAYED_ALBUMIMAGE:
204                     albumUpdater.refreshAlbumImage(channelUID);
205                     break;
206                 case CHANNEL_PLAYLISTS:
207                     playlistCache.invalidateValue();
208                     break;
209                 case CHANNEL_ACCESSTOKEN:
210                     onAccessTokenResponse(getAccessTokenResponse());
211                     break;
212                 default:
213                     lastTrackId = StringType.EMPTY;
214                     break;
215             }
216         } else {
217             try {
218                 if (handleCommand != null
219                         && handleCommand.handleCommand(channelUID, command, lastKnownDeviceActive, lastKnownDeviceId)) {
220                     scheduler.schedule(this::scheduledPollingRestart, POLL_DELAY_AFTER_COMMAND_S, TimeUnit.SECONDS);
221                 }
222             } catch (final SpotifyException e) {
223                 logger.debug("Handle Spotify command failed: ", e);
224                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
225             }
226         }
227     }
228
229     @Override
230     public void dispose() {
231         active = false;
232         if (oAuthService != null) {
233             oAuthService.removeAccessTokenRefreshListener(this);
234         }
235         oAuthFactory.ungetOAuthService(thing.getUID().getAsString());
236         cancelSchedulers();
237     }
238
239     @Override
240     public ThingUID getUID() {
241         return thing.getUID();
242     }
243
244     @Override
245     public String getLabel() {
246         return thing.getLabel() == null ? "" : thing.getLabel().toString();
247     }
248
249     @Override
250     public boolean isAuthorized() {
251         final AccessTokenResponse accessTokenResponse = getAccessTokenResponse();
252
253         return accessTokenResponse != null && accessTokenResponse.getAccessToken() != null
254                 && accessTokenResponse.getRefreshToken() != null;
255     }
256
257     private @Nullable AccessTokenResponse getAccessTokenResponse() {
258         try {
259             return oAuthService == null ? null : oAuthService.getAccessTokenResponse();
260         } catch (OAuthException | IOException | OAuthResponseException | RuntimeException e) {
261             logger.debug("Exception checking authorization: ", e);
262             return null;
263         }
264     }
265
266     @Override
267     public String getUser() {
268         return thing.getProperties().getOrDefault(PROPERTY_SPOTIFY_USER, "");
269     }
270
271     @Override
272     public boolean isOnline() {
273         return thing.getStatus() == ThingStatus.ONLINE;
274     }
275
276     @Nullable
277     public SpotifyApi getSpotifyApi() {
278         return spotifyApi;
279     }
280
281     @Override
282     public boolean equalsThingUID(String thingUID) {
283         return getThing().getUID().getAsString().equals(thingUID);
284     }
285
286     @Override
287     public String formatAuthorizationUrl(String redirectUri) {
288         try {
289             return oAuthService.getAuthorizationUrl(redirectUri, null, thing.getUID().getAsString());
290         } catch (final OAuthException e) {
291             logger.debug("Error constructing AuthorizationUrl: ", e);
292             return "";
293         }
294     }
295
296     @Override
297     public String authorize(String redirectUri, String reqCode) {
298         try {
299             logger.debug("Make call to Spotify to get access token.");
300             final AccessTokenResponse credentials = oAuthService.getAccessTokenResponseByAuthorizationCode(reqCode,
301                     redirectUri);
302             final String user = updateProperties(credentials);
303             logger.debug("Authorized for user: {}", user);
304             startPolling();
305             return user;
306         } catch (RuntimeException | OAuthException | IOException e) {
307             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
308             throw new SpotifyException(e.getMessage(), e);
309         } catch (final OAuthResponseException e) {
310             throw new SpotifyAuthorizationException(e.getMessage(), e);
311         }
312     }
313
314     private String updateProperties(AccessTokenResponse credentials) {
315         if (spotifyApi != null) {
316             final Me me = spotifyApi.getMe();
317             final String user = me.getDisplayName() == null ? me.getId() : me.getDisplayName();
318             final Map<String, String> props = editProperties();
319
320             props.put(PROPERTY_SPOTIFY_USER, user);
321             updateProperties(props);
322             return user;
323         }
324         return "";
325     }
326
327     @Override
328     public void initialize() {
329         updateStatus(ThingStatus.UNKNOWN);
330         active = true;
331         configuration = getConfigAs(SpotifyBridgeConfiguration.class);
332         oAuthService = oAuthFactory.createOAuthClientService(thing.getUID().getAsString(), SPOTIFY_API_TOKEN_URL,
333                 SPOTIFY_AUTHORIZE_URL, configuration.clientId, configuration.clientSecret, SPOTIFY_SCOPES, true);
334         oAuthService.addAccessTokenRefreshListener(SpotifyBridgeHandler.this);
335         spotifyApi = new SpotifyApi(oAuthService, scheduler, httpClient);
336         handleCommand = new SpotifyHandleCommands(spotifyApi);
337         final Duration expiringPeriod = Duration.ofSeconds(configuration.refreshPeriod);
338
339         playingContextCache = new ExpiringCache<>(expiringPeriod, spotifyApi::getPlayerInfo);
340         final int offset = getIntChannelParameter(CHANNEL_PLAYLISTS, CHANNEL_PLAYLISTS_OFFSET, 0);
341         final int limit = getIntChannelParameter(CHANNEL_PLAYLISTS, CHANNEL_PLAYLISTS_LIMIT, 20);
342         playlistCache = new ExpiringCache<>(POLL_PLAY_LIST_HOURS, () -> spotifyApi.getPlaylists(offset, limit));
343         devicesCache = new ExpiringCache<>(expiringPeriod, spotifyApi::getDevices);
344
345         // Start with update status by calling Spotify. If no credentials available no polling should be started.
346         scheduler.execute(() -> {
347             if (pollStatus()) {
348                 startPolling();
349             }
350         });
351         imageChannelAlbumImageIndex = getIntChannelParameter(CHANNEL_PLAYED_ALBUMIMAGE, CHANNEL_CONFIG_IMAGE_INDEX, 0);
352         imageChannelAlbumImageUrlIndex = getIntChannelParameter(CHANNEL_PLAYED_ALBUMIMAGEURL,
353                 CHANNEL_CONFIG_IMAGE_INDEX, 0);
354     }
355
356     private int getIntChannelParameter(String channelName, String parameter, int _default) {
357         final Channel channel = thing.getChannel(channelName);
358         final BigDecimal index = channel == null ? null : (BigDecimal) channel.getConfiguration().get(parameter);
359
360         return index == null ? _default : index.intValue();
361     }
362
363     @Override
364     public List<Device> listDevices() {
365         final List<Device> listDevices = devicesCache.getValue();
366
367         return listDevices == null ? Collections.emptyList() : listDevices;
368     }
369
370     /**
371      * Scheduled method to restart polling in case polling is not running.
372      */
373     private void scheduledPollingRestart() {
374         synchronized (pollSynchronization) {
375             try {
376                 final boolean pollingNotRunning = pollingFuture == null || pollingFuture.isCancelled();
377
378                 expireCache();
379                 if (pollStatus() && pollingNotRunning) {
380                     startPolling();
381                 }
382             } catch (final RuntimeException e) {
383                 logger.debug("Restarting polling failed: ", e);
384             }
385         }
386     }
387
388     /**
389      * This method initiates a new thread for polling the available Spotify Connect devices and update the player
390      * information.
391      */
392     private void startPolling() {
393         synchronized (pollSynchronization) {
394             cancelSchedulers();
395             if (active) {
396                 expireCache();
397                 pollingFuture = scheduler.scheduleWithFixedDelay(this::pollStatus, 0, configuration.refreshPeriod,
398                         TimeUnit.SECONDS);
399             }
400         }
401     }
402
403     private void expireCache() {
404         playingContextCache.invalidateValue();
405         playlistCache.invalidateValue();
406         devicesCache.invalidateValue();
407     }
408
409     /**
410      * Calls the Spotify API and collects user data. Returns true if method completed without errors.
411      *
412      * @return true if method completed without errors.
413      */
414     private boolean pollStatus() {
415         synchronized (pollSynchronization) {
416             try {
417                 onAccessTokenResponse(getAccessTokenResponse());
418                 // Collect currently playing context.
419                 final CurrentlyPlayingContext pc = playingContextCache.getValue();
420                 // If Spotify returned a 204. Meaning everything is ok, but we got no data.
421                 // Happens when no song is playing. And we know no device was active
422                 // No need to continue because no new information will be available.
423                 final boolean hasPlayData = pc != null && pc.getDevice() != null;
424                 final CurrentlyPlayingContext playingContext = pc == null ? EMPTY_CURRENTLY_PLAYING_CONTEXT : pc;
425
426                 // Collect devices and populate selection with available devices.
427                 if (hasPlayData) {
428                     final List<Device> ld = devicesCache.getValue();
429                     final List<Device> devices = ld == null ? Collections.emptyList() : ld;
430                     spotifyDynamicStateDescriptionProvider.setDevices(devicesChannelUID, devices);
431                     handleCommand.setDevices(devices);
432                     updateDevicesStatus(devices, playingContext.isPlaying());
433                 }
434
435                 // Update play status information.
436                 if (hasPlayData || getThing().getStatus() == ThingStatus.UNKNOWN) {
437                     final List<Playlist> lp = playlistCache.getValue();
438                     final List<Playlist> playlists = lp == null ? Collections.emptyList() : lp;
439                     handleCommand.setPlaylists(playlists);
440                     updatePlayerInfo(playingContext, playlists);
441                     spotifyDynamicStateDescriptionProvider.setPlayLists(playlistsChannelUID, playlists);
442                 }
443                 updateStatus(ThingStatus.ONLINE);
444                 return true;
445             } catch (final SpotifyAuthorizationException e) {
446                 logger.debug("Authorization error during polling: ", e);
447
448                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
449                 cancelSchedulers();
450                 devicesCache.invalidateValue();
451             } catch (final SpotifyException e) {
452                 logger.info("Spotify returned an error during polling: {}", e.getMessage());
453
454                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
455             } catch (final RuntimeException e) {
456                 // This only should catch RuntimeException as the apiCall don't throw other exceptions.
457                 logger.info("Unexpected error during polling status, please report if this keeps occurring: ", e);
458
459                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
460             }
461         }
462         return false;
463     }
464
465     /**
466      * Cancels all running schedulers.
467      */
468     private synchronized void cancelSchedulers() {
469         if (pollingFuture != null) {
470             pollingFuture.cancel(true);
471         }
472         progressUpdater.cancelProgressScheduler();
473     }
474
475     @Override
476     public void onAccessTokenResponse(@Nullable AccessTokenResponse tokenResponse) {
477         updateChannelState(CHANNEL_ACCESSTOKEN,
478                 new StringType(tokenResponse == null ? null : tokenResponse.getAccessToken()));
479     }
480
481     /**
482      * Updates the status of all child Spotify Device Things.
483      *
484      * @param spotifyDevices list of Spotify devices
485      * @param playing true if the current active device is playing
486      */
487     private void updateDevicesStatus(List<Device> spotifyDevices, boolean playing) {
488         getThing().getThings().stream() //
489                 .filter(thing -> thing.getHandler() instanceof SpotifyDeviceHandler) //
490                 .filter(thing -> !spotifyDevices.stream()
491                         .anyMatch(sd -> ((SpotifyDeviceHandler) thing.getHandler()).updateDeviceStatus(sd, playing)))
492                 .forEach(thing -> ((SpotifyDeviceHandler) thing.getHandler()).setStatusGone());
493     }
494
495     /**
496      * Update the player data.
497      *
498      * @param playerInfo The object with the current playing context
499      * @param playlists List of available playlists
500      */
501     private void updatePlayerInfo(CurrentlyPlayingContext playerInfo, List<Playlist> playlists) {
502         updateChannelState(CHANNEL_TRACKPLAYER, playerInfo.isPlaying() ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
503         updateChannelState(CHANNEL_DEVICESHUFFLE, OnOffType.from(playerInfo.isShuffleState()));
504         updateChannelState(CHANNEL_TRACKREPEAT, playerInfo.getRepeatState());
505
506         final boolean hasItem = playerInfo.getItem() != null;
507         final Item item = hasItem ? playerInfo.getItem() : EMPTY_ITEM;
508         final State trackId = valueOrEmpty(item.getId());
509
510         progressUpdater.updateProgress(active, playerInfo.isPlaying(), item.getDurationMs(),
511                 playerInfo.getProgressMs());
512         if (!lastTrackId.equals(trackId)) {
513             lastTrackId = trackId;
514             updateChannelState(CHANNEL_PLAYED_TRACKDURATION_MS, new DecimalType(item.getDurationMs()));
515             final String formattedProgress;
516             synchronized (MUSIC_TIME_FORMAT) {
517                 // synchronize because SimpleDateFormat is not thread safe
518                 formattedProgress = MUSIC_TIME_FORMAT.format(new Date(item.getDurationMs()));
519             }
520             updateChannelState(CHANNEL_PLAYED_TRACKDURATION_FMT, formattedProgress);
521
522             updateChannelsPlayList(playerInfo, playlists);
523             updateChannelState(CHANNEL_PLAYED_TRACKID, lastTrackId);
524             updateChannelState(CHANNEL_PLAYED_TRACKHREF, valueOrEmpty(item.getHref()));
525             updateChannelState(CHANNEL_PLAYED_TRACKURI, valueOrEmpty(item.getUri()));
526             updateChannelState(CHANNEL_PLAYED_TRACKNAME, valueOrEmpty(item.getName()));
527             updateChannelState(CHANNEL_PLAYED_TRACKTYPE, valueOrEmpty(item.getType()));
528             updateChannelState(CHANNEL_PLAYED_TRACKNUMBER, valueOrZero(item.getTrackNumber()));
529             updateChannelState(CHANNEL_PLAYED_TRACKDISCNUMBER, valueOrZero(item.getDiscNumber()));
530             updateChannelState(CHANNEL_PLAYED_TRACKPOPULARITY, valueOrZero(item.getPopularity()));
531             updateChannelState(CHANNEL_PLAYED_TRACKEXPLICIT, OnOffType.from(item.isExplicit()));
532
533             final boolean hasAlbum = hasItem && item.getAlbum() != null;
534             final Album album = hasAlbum ? item.getAlbum() : EMPTY_ALBUM;
535             updateChannelState(CHANNEL_PLAYED_ALBUMID, valueOrEmpty(album.getId()));
536             updateChannelState(CHANNEL_PLAYED_ALBUMHREF, valueOrEmpty(album.getHref()));
537             updateChannelState(CHANNEL_PLAYED_ALBUMURI, valueOrEmpty(album.getUri()));
538             updateChannelState(CHANNEL_PLAYED_ALBUMNAME, valueOrEmpty(album.getName()));
539             updateChannelState(CHANNEL_PLAYED_ALBUMTYPE, valueOrEmpty(album.getType()));
540             albumUpdater.updateAlbumImage(album);
541
542             final Artist firstArtist = hasItem && item.getArtists() != null && !item.getArtists().isEmpty()
543                     ? item.getArtists().get(0)
544                     : EMPTY_ARTIST;
545
546             updateChannelState(CHANNEL_PLAYED_ARTISTID, valueOrEmpty(firstArtist.getId()));
547             updateChannelState(CHANNEL_PLAYED_ARTISTHREF, valueOrEmpty(firstArtist.getHref()));
548             updateChannelState(CHANNEL_PLAYED_ARTISTURI, valueOrEmpty(firstArtist.getUri()));
549             updateChannelState(CHANNEL_PLAYED_ARTISTNAME, valueOrEmpty(firstArtist.getName()));
550             updateChannelState(CHANNEL_PLAYED_ARTISTTYPE, valueOrEmpty(firstArtist.getType()));
551         }
552         final Device device = playerInfo.getDevice() == null ? EMPTY_DEVICE : playerInfo.getDevice();
553         // Only update lastKnownDeviceId if it has a value, otherwise keep old value.
554         if (device.getId() != null) {
555             lastKnownDeviceId = device.getId();
556             updateChannelState(CHANNEL_DEVICEID, valueOrEmpty(lastKnownDeviceId));
557             updateChannelState(CHANNEL_DEVICES, valueOrEmpty(lastKnownDeviceId));
558             updateChannelState(CHANNEL_DEVICENAME, valueOrEmpty(device.getName()));
559         }
560         lastKnownDeviceActive = device.isActive();
561         updateChannelState(CHANNEL_DEVICEACTIVE, OnOffType.from(lastKnownDeviceActive));
562         updateChannelState(CHANNEL_DEVICETYPE, valueOrEmpty(device.getType()));
563
564         // experienced situations where volume seemed to be undefined...
565         updateChannelState(CHANNEL_DEVICEVOLUME,
566                 device.getVolumePercent() == null ? UnDefType.UNDEF : new PercentType(device.getVolumePercent()));
567     }
568
569     private void updateChannelsPlayList(CurrentlyPlayingContext playerInfo, @Nullable List<Playlist> playlists) {
570         final Context context = playerInfo.getContext();
571         final String playlistId;
572         String playlistName = "";
573
574         if (context != null && "playlist".equals(context.getType())) {
575             playlistId = "spotify:playlist" + context.getUri().substring(context.getUri().lastIndexOf(':'));
576
577             if (playlists != null) {
578                 final Optional<Playlist> optionalPlaylist = playlists.stream()
579                         .filter(pl -> playlistId.equals(pl.getUri())).findFirst();
580
581                 playlistName = optionalPlaylist.isPresent() ? optionalPlaylist.get().getName() : "";
582             }
583         } else {
584             playlistId = "";
585         }
586         updateChannelState(CHANNEL_PLAYLISTS, valueOrEmpty(playlistId));
587         updateChannelState(CHANNEL_PLAYLISTNAME, valueOrEmpty(playlistName));
588     }
589
590     /**
591      * @param value Integer value to return as {@link DecimalType}
592      * @return value as {@link DecimalType} or ZERO if the value is null
593      */
594     private DecimalType valueOrZero(@Nullable Integer value) {
595         return value == null ? DecimalType.ZERO : new DecimalType(value);
596     }
597
598     /**
599      * @param value String value to return as {@link StringType}
600      * @return value as {@link StringType} or EMPTY if the value is null or empty
601      */
602     private StringType valueOrEmpty(@Nullable String value) {
603         return value == null || value.isEmpty() ? StringType.EMPTY : new StringType(value);
604     }
605
606     /**
607      * Convenience method to update the channel state as {@link StringType} with a {@link String} value
608      *
609      * @param channelId id of the channel to update
610      * @param value String value to set as {@link StringType}
611      */
612     private void updateChannelState(String channelId, String value) {
613         updateChannelState(channelId, new StringType(value));
614     }
615
616     /**
617      * Convenience method to update the channel state but only if the channel is linked.
618      *
619      * @param channelId id of the channel to update
620      * @param state State to set on the channel
621      */
622     private void updateChannelState(String channelId, State state) {
623         final Channel channel = thing.getChannel(channelId);
624
625         if (channel != null && isLinked(channel.getUID())) {
626             updateState(channel.getUID(), state);
627         }
628     }
629
630     /**
631      * Class that manages the current progress of a track. The actual progress is tracked with the user specified
632      * interval, This class fills the in between seconds so the status will show a continues updating of the progress.
633      *
634      * @author Hilbrand Bouwkamp - Initial contribution
635      */
636     private class ProgressUpdater {
637         private long progress;
638         private long duration;
639         private @NonNullByDefault({}) Future<?> progressFuture;
640
641         /**
642          * Updates the progress with its actual values as provided by Spotify. Based on if the track is running or not
643          * update the progress scheduler.
644          *
645          * @param active true if this instance is not disposed
646          * @param playing true if the track if playing
647          * @param duration duration of the track
648          * @param progress current progress of the track
649          */
650         public synchronized void updateProgress(boolean active, boolean playing, long duration, long progress) {
651             this.duration = duration;
652             setProgress(progress);
653             if (!playing || !active) {
654                 cancelProgressScheduler();
655             } else if ((progressFuture == null || progressFuture.isCancelled()) && active) {
656                 progressFuture = scheduler.scheduleWithFixedDelay(this::incrementProgress, PROGRESS_STEP_S,
657                         PROGRESS_STEP_S, TimeUnit.SECONDS);
658             }
659         }
660
661         /**
662          * Increments the progress with PROGRESS_STEP_MS, but limits it on the duration.
663          */
664         private synchronized void incrementProgress() {
665             setProgress(Math.min(duration, progress + PROGRESS_STEP_MS));
666         }
667
668         /**
669          * Sets the progress on the channels.
670          *
671          * @param progress progress value to set
672          */
673         private void setProgress(long progress) {
674             this.progress = progress;
675             final String formattedProgress;
676
677             synchronized (MUSIC_TIME_FORMAT) {
678                 formattedProgress = MUSIC_TIME_FORMAT.format(new Date(progress));
679             }
680             updateChannelState(CHANNEL_PLAYED_TRACKPROGRESS_MS, new DecimalType(progress));
681             updateChannelState(CHANNEL_PLAYED_TRACKPROGRESS_FMT, formattedProgress);
682         }
683
684         /**
685          * Cancels the progress future.
686          */
687         public synchronized void cancelProgressScheduler() {
688             if (progressFuture != null) {
689                 progressFuture.cancel(true);
690                 progressFuture = null;
691             }
692         }
693     }
694
695     /**
696      * Class to manager Album image updates.
697      *
698      * @author Hilbrand Bouwkamp - Initial contribution
699      */
700     private class AlbumUpdater {
701         private String lastAlbumImageUrl = "";
702
703         /**
704          * Updates the album image status, but only refreshes the image when a new image should be shown.
705          *
706          * @param album album data
707          */
708         public void updateAlbumImage(Album album) {
709             final Channel imageChannel = thing.getChannel(CHANNEL_PLAYED_ALBUMIMAGE);
710             final List<Image> images = album.getImages();
711
712             // Update album image url channel
713             final String albumImageUrlUrl = albumUrl(images, imageChannelAlbumImageUrlIndex);
714             updateChannelState(CHANNEL_PLAYED_ALBUMIMAGEURL,
715                     albumImageUrlUrl == null ? UnDefType.UNDEF : StringType.valueOf(albumImageUrlUrl));
716
717             // Trigger image refresh of album image channel
718             final String albumImageUrl = albumUrl(images, imageChannelAlbumImageIndex);
719             if (imageChannel != null && albumImageUrl != null) {
720                 if (!lastAlbumImageUrl.equals(albumImageUrl)) {
721                     // Download the cover art in a different thread to not delay the other operations
722                     lastAlbumImageUrl = albumImageUrl;
723                     refreshAlbumImage(imageChannel.getUID());
724                 } // else album image still the same so nothing to do
725             } else {
726                 lastAlbumImageUrl = "";
727                 updateChannelState(CHANNEL_PLAYED_ALBUMIMAGE, UnDefType.UNDEF);
728             }
729         }
730
731         private @Nullable String albumUrl(@Nullable List<Image> images, int index) {
732             return images == null || index >= images.size() || images.isEmpty() ? null : images.get(index).getUrl();
733         }
734
735         /**
736          * Refreshes the image asynchronously, but only downloads the image if the channel is linked to avoid
737          * unnecessary downloading of the image.
738          *
739          * @param channelUID UID of the album channel
740          */
741         public void refreshAlbumImage(ChannelUID channelUID) {
742             if (!lastAlbumImageUrl.isEmpty() && isLinked(channelUID)) {
743                 final String imageUrl = lastAlbumImageUrl;
744                 scheduler.execute(() -> refreshAlbumAsynced(channelUID, imageUrl));
745             }
746         }
747
748         private void refreshAlbumAsynced(ChannelUID channelUID, String imageUrl) {
749             try {
750                 if (lastAlbumImageUrl.equals(imageUrl) && isLinked(channelUID)) {
751                     final RawType image = HttpUtil.downloadImage(imageUrl, true, MAX_IMAGE_SIZE);
752                     updateChannelState(CHANNEL_PLAYED_ALBUMIMAGE, image == null ? UnDefType.UNDEF : image);
753                 }
754             } catch (final RuntimeException e) {
755                 logger.debug("Async call to refresh Album image failed: ", e);
756             }
757         }
758     }
759 }